收口原生壳请求超时边界

将原生壳请求默认超时和最大超时提升到共享 HostBridge 契约

让 H5 nativeAppHostBridge 复用共享超时边界

增加原生壳门禁拦截本地超时边界重声明

补充原生壳方案和共享决策记录
This commit is contained in:
2026-06-19 13:26:01 +08:00
parent 8aaa5e93e2
commit bd0f566e76
8 changed files with 109 additions and 6 deletions
@@ -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 ?? [],
@@ -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 门禁和现有验收口径静默漂移。
@@ -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 自己跳转而掉回普通浏览器运行态。
@@ -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',
@@ -216,6 +216,8 @@ export type HostBridgeRequest<Payload = unknown> = {
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) {
+32
View File
@@ -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();
@@ -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<string, unknown>) => {
@@ -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,
);
}