收口宿主响应回放缓存边界
将 HostBridge 响应回放缓存上限提升到共享契约 让 Expo 移动壳复用共享缓存上限并拒绝本地重声明 让 Tauri 桌面壳配置检查反查共享缓存上限 补充两端回放缓存淘汰测试和壳方案文档
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 校验
|
||||
|
||||
|
||||
@@ -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 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -218,6 +218,7 @@ export type HostBridgeRequest<Payload = unknown> = {
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user