From bd0f566e76210e1e3693e4ce374b3f0301b88f1e Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 13:26:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=8E=9F=E7=94=9F=E5=A3=B3?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=B6=85=E6=97=B6=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原生壳请求默认超时和最大超时提升到共享 HostBridge 契约 让 H5 nativeAppHostBridge 复用共享超时边界 增加原生壳门禁拦截本地超时边界重声明 补充原生壳方案和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 12 ++++++ .../shared-memory/decision-log.md | 7 ++++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- .../shared/src/contracts/hostBridge.test.ts | 7 ++++ packages/shared/src/contracts/hostBridge.ts | 2 + scripts/check-native-shells.mjs | 32 ++++++++++++++ .../host-bridge/nativeAppHostBridge.test.ts | 42 +++++++++++++++++++ .../host-bridge/nativeAppHostBridge.ts | 9 ++-- 8 files changed, 109 insertions(+), 6 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index a271d10de..b73498d3c 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1777,6 +1777,8 @@ if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) { throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND'); } for (const snippet of [ + 'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS', + 'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS', 'function createNativeHostBridgeTimeoutError()', 'isHostBridgeEventName(candidate.event)', 'async function invokeTauriHostBridgeWithTimeout', @@ -1788,6 +1790,16 @@ for (const snippet of [ throw new Error(`H5 native app Tauri transport missing ${snippet}`); } } +for (const staleTimeoutBoundary of [ + 'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS', + 'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS', +]) { + if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) { + throw new Error( + `H5 native app transport must use shared HostBridge timeout boundary instead of ${staleTimeoutBoundary}`, + ); + } +} assertSameList( capability.windows ?? [], diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 51656f104..3becb5f4c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2517,6 +2517,13 @@ - 影响范围:`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 原生壳请求超时边界单一来源 + +- 背景:H5 的 React Native WebView transport 和 Tauri transport 已共享请求超时语义,但默认超时与最大超时如果继续留在 H5 transport 本地常量中,后续共享契约、测试和壳配置门禁容易出现边界漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS` 与 `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS`,作为原生壳请求默认超时和最大超时的唯一声明来源;`src/services/host-bridge/nativeAppHostBridge.ts` 必须导入共享常量做 `timeoutMs` 归一化,不得在 H5 transport 本地重声明默认 / 最大超时。`npm run check:native-shells` 和桌面壳配置检查会拒绝回退到本地超时边界。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/nativeAppHostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 原生壳关键依赖版本收口 - 背景:Expo / React Native WebView / Tauri / Cargo 插件版本会直接影响 WebView 安全默认值、managed config 解析、production bundle、Tauri capability、插件初始化和 release 构建行为;如果只改依赖声明,壳行为可能绕过 HostBridge 门禁和现有验收口径静默漂移。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 85a6c7bb3..c9d6a3883 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` 释放请求,宿主侧执行超时也只能返回标准 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 回放首次完成结果。 - 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.*` 或页面工厂调用和对应测试清单。 @@ -449,6 +449,8 @@ GameBridge 禁止: 2026-06-18 追加:H5 到 Tauri 的 `core.invoke` 也必须经过 `nativeAppHostBridge` 的超时封装,不能裸等 Rust command Promise。Tauri transport 与 React Native WebView transport 共享 `timeoutMs` 归一化和 `timeout / host_bridge_timeout` 错误语义;如果桌面宿主迟到返回,H5 侧已拒绝的请求结果不得被改写。`apps/desktop-shell/scripts/check-config.mjs` 会锁定 H5 Tauri transport 的超时封装,避免后续退回裸 `invoke`。 +2026-06-19 追加:原生壳请求超时边界进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS=8000` 与 `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS=60000`;H5 `nativeAppHostBridge` 的 React Native WebView transport 和 Tauri transport 都必须导入这两个常量做 `timeoutMs` 归一化,不得在 H5 transport 本地重声明默认 / 最大超时。`npm run check:native-shells` 和桌面壳配置检查会拒绝回退到本地超时边界。 + 2026-06-18 追加:原生壳注入消息来源进入门禁。Expo 和 Tauri 注入给 H5 的 HostBridge response / event 都显式带 `origin: window.location.origin` 和 `source: window`;H5 `nativeAppHostBridge` listener 会忽略带非当前窗口 source 或非当前页面 origin 的 message。这样后续 AI sandbox iframe 即使能向父页面 `postMessage` 同形 envelope,也不能结算宿主请求或伪造宿主事件;GameBridge 继续走单独 allowlist。 2026-06-19 追加:H5 页面内应用导航会保留完整原生宿主上下文。`pushAppHistoryPath()` 和 `replaceAppHistoryPath()` 必须通过共享 `HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 补齐 `clientType`、`clientRuntime`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`,并用应用 history state 标记 H5 自己写入的导航条目。这样直达二级页补返回锚点、平台内页面切换和原生壳 runtime 能力刷新不会因为 H5 自己跳转而掉回普通浏览器运行态。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index cbda21c10..c278fbdc4 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -17,8 +17,10 @@ import { HOST_BRIDGE_TAURI_COMMAND, HOST_BRIDGE_CAPABILITIES, HOST_BRIDGE_DOCUMENT_MIME_TYPES, + HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS, HOST_BRIDGE_EVENTS, HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, + HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, HOST_BRIDGE_TEXT_MIME_TYPES, isHostBridgeMethod, isHostBridgeCapability, @@ -46,6 +48,11 @@ describe('HostBridge shared contract helpers', () => { expect(HOST_BRIDGE_TAURI_COMMAND).toBe('host_bridge_request'); }); + test('固定原生壳请求超时边界', () => { + expect(HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS).toBe(8000); + expect(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS).toBe(60000); + }); + 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 ac8fefc02..06a96bf00 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -216,6 +216,8 @@ export type HostBridgeRequest = { timeoutMs?: number; }; +export const HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS = 8000; +export const HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS = 60000; export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120; export function normalizeHostBridgeRequestId(rawId: unknown) { diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index b6aa2b585..942f9a006 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1011,6 +1011,35 @@ function assertH5HostBridgePayloadBoundaries() { } } +function assertH5NativeAppTransportTimeoutBoundaries() { + const nativeAppHostBridgeSource = fs.readFileSync( + 'src/services/host-bridge/nativeAppHostBridge.ts', + 'utf8', + ); + + for (const sharedBoundary of [ + 'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS', + 'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS', + ]) { + if (!nativeAppHostBridgeSource.includes(`${sharedBoundary},`)) { + throw new Error( + `H5 native app transport must import shared timeout boundary ${sharedBoundary}`, + ); + } + } + + for (const staleTimeoutBoundary of [ + 'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS', + 'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS', + ]) { + if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) { + throw new Error( + `H5 native app transport must not redeclare timeout boundary ${staleTimeoutBoundary}`, + ); + } + } +} + function extractDocumentCapabilityList(source, marker) { const markerIndex = source.indexOf(marker); if (markerIndex === -1) { @@ -1662,6 +1691,9 @@ assertH5HostBridgeEventSubscriptionGates(); console.log('[check:native-shells] h5-host-bridge-payload-boundaries'); assertH5HostBridgePayloadBoundaries(); +console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries'); +assertH5NativeAppTransportTimeoutBoundaries(); + console.log('[check:native-shells] production-shell-dev-scaffold-scan'); assertNoProductionShellDevScaffoldTerms(); diff --git a/src/services/host-bridge/nativeAppHostBridge.test.ts b/src/services/host-bridge/nativeAppHostBridge.test.ts index eb3a92d0d..9308990a8 100644 --- a/src/services/host-bridge/nativeAppHostBridge.test.ts +++ b/src/services/host-bridge/nativeAppHostBridge.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { + HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS, + HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_TAURI_COMMAND, HOST_BRIDGE_VERSION, @@ -146,6 +148,46 @@ describe('nativeAppHostBridge', () => { expect(vi.getTimerCount()).toBe(0); }); + test('未指定超时时使用共享默认请求超时', async () => { + vi.useFakeTimers(); + window.ReactNativeWebView = { + postMessage: vi.fn(), + }; + + const pending = requestNativeAppHostBridge('share.open'); + const assertion = expect(pending).rejects.toMatchObject({ + name: 'timeout', + message: 'host_bridge_timeout', + }); + await vi.advanceTimersByTimeAsync(HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS - 1); + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1); + + await assertion; + }); + + test('超出上限的请求超时会收敛到共享最大值', async () => { + vi.useFakeTimers(); + window.ReactNativeWebView = { + postMessage: vi.fn(), + }; + + const pending = requestNativeAppHostBridge('share.open', undefined, { + timeoutMs: HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS + 1000, + }); + const assertion = expect(pending).rejects.toMatchObject({ + name: 'timeout', + message: 'host_bridge_timeout', + }); + await vi.advanceTimersByTimeAsync(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS - 1); + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1); + + await assertion; + }); + test('Tauri invoke 成功回包后清理超时计时器', async () => { vi.useFakeTimers(); const invoke = vi.fn(async (_command: string, args?: Record) => { diff --git a/src/services/host-bridge/nativeAppHostBridge.ts b/src/services/host-bridge/nativeAppHostBridge.ts index 1cb0a22f3..9bc323d3a 100644 --- a/src/services/host-bridge/nativeAppHostBridge.ts +++ b/src/services/host-bridge/nativeAppHostBridge.ts @@ -1,4 +1,6 @@ import { + HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS, + HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_TAURI_COMMAND, HOST_BRIDGE_VERSION, @@ -11,9 +13,6 @@ import { isHostBridgeEventName, } from '../../../packages/shared/src/contracts/hostBridge'; -const DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS = 8000; -const MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS = 60000; - type NativeAppBridgeWindow = Window & { ReactNativeWebView?: { postMessage?: (message: string) => void; @@ -58,12 +57,12 @@ function buildNativeRequestId(method: HostBridgeMethod) { function resolveTimeoutMs(timeoutMs: number | undefined) { if (!Number.isFinite(timeoutMs ?? Number.NaN) || !timeoutMs) { - return DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS; + return HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS; } return Math.min( Math.max(1, Math.trunc(timeoutMs)), - MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS, + HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, ); }