收口H5桥接载荷边界

让H5 HostBridge facade 复用共享MIME清单

增加原生壳门禁拦截H5本地复制MIME边界

补充共享决策记录中的载荷边界约定
This commit is contained in:
2026-06-19 13:19:12 +08:00
parent 3439250419
commit 8aaa5e93e2
3 changed files with 60 additions and 28 deletions
@@ -30,6 +30,7 @@
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。
- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES``miniprogram/host-bridge/protocol.test.js` 和根级 `npm run check:native-shells` 反查共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES`。新增能力必须先更新契约和真实壳实现,再通过这些检查。
- 2026-06-19 宿主上下文 query 契约收口:`packages/shared/src/contracts/hostBridge.ts` 是宿主上下文 query 字段和值的唯一 TypeScript 来源;`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime parser 字段,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要保留的宿主字段。Expo 壳直接引用共享常量,Tauri Rust 和微信 CommonJS 镜像由 `npm run check:native-shells` / 单壳配置检查反查;微信请求头必须从 `WEB_VIEW_SOURCE_QUERY` 读取 `clientType` / `clientRuntime`,不得另起常量。
- 2026-06-19 H5 HostBridge 载荷边界收口:`src/services/host-bridge/hostBridge.ts` 作为 H5 facade 也必须直接导入 `HOST_BRIDGE_TEXT_MIME_TYPES``HOST_BRIDGE_DOCUMENT_MIME_TYPES``HOST_BRIDGE_IMAGE_MIME_TYPES``HOST_BRIDGE_AUDIO_MIME_TYPES`,只能从共享契约派生本地 Set 用于归一化,不得重新写 MIME 字面量清单;`npm run check:native-shells` 会拒绝 H5 facade 重新复制文本、图片或音频 MIME 边界。
- 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和 H5 HostBridge 真实调用链禁替身验收散落成容易漏跑的单项命令。
- 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件;新增 H5 调用点接入 HostBridge 时,必须让自动扫描覆盖对应文件或在同等门禁中证明生产代码没有 mock / fake / stub / TODO / FIXME / 模拟 / 伪造。
- 2026-06-18 微信壳桥接层纳入统一验收:`npm run check:native-shells` 还会运行 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的微信壳测试,覆盖 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图行为;三端桥接层文件结构检查只证明目录边界,行为回归必须由同一门禁中的微信壳测试证明。
+49
View File
@@ -965,6 +965,52 @@ function assertH5HostBridgeEventSubscriptionGates() {
}
}
function assertH5HostBridgePayloadBoundaries() {
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
for (const sharedBoundary of [
'HOST_BRIDGE_TEXT_MIME_TYPES',
'HOST_BRIDGE_DOCUMENT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
]) {
if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) {
throw new Error(
`H5 HostBridge facade must import shared payload boundary ${sharedBoundary}`,
);
}
if (new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(h5HostBridgeSource)) {
throw new Error(
`H5 HostBridge facade must not redeclare shared payload boundary ${sharedBoundary}`,
);
}
}
for (const mimeLiteral of [
"'text/plain'",
"'text/markdown'",
"'text/csv'",
"'application/json'",
"'image/png'",
"'image/jpeg'",
"'image/webp'",
"'audio/mpeg'",
"'audio/mp4'",
"'audio/wav'",
"'audio/ogg'",
"'audio/webm'",
]) {
if (h5HostBridgeSource.includes(mimeLiteral)) {
throw new Error(
`H5 HostBridge facade must read MIME boundaries from shared contract instead of ${mimeLiteral}`,
);
}
}
}
function extractDocumentCapabilityList(source, marker) {
const markerIndex = source.indexOf(marker);
if (markerIndex === -1) {
@@ -1613,6 +1659,9 @@ assertWechatMiniProgramCapabilityFlows();
console.log('[check:native-shells] h5-host-bridge-event-subscription-gates');
assertH5HostBridgeEventSubscriptionGates();
console.log('[check:native-shells] h5-host-bridge-payload-boundaries');
assertH5HostBridgePayloadBoundaries();
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();
+10 -28
View File
@@ -13,13 +13,9 @@ import type {
FileImportImageResult,
FileImportTextResult,
HapticsImpactPayload,
HostBridgeAudioMimeType,
HostBridgeCapability,
HostBridgeDocumentMimeType,
HostBridgeImageMimeType,
HostBridgeMethod,
HostBridgeRuntimeResult,
HostBridgeTextMimeType,
LocalNotificationPayload,
NavigationCanGoBackEventPayload,
NetworkStatusResult,
@@ -29,9 +25,12 @@ import type {
ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
import {
HOST_BRIDGE_AUDIO_MIME_TYPES,
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
HOST_BRIDGE_IMAGE_MIME_TYPES,
HOST_BRIDGE_NATIVE_APP_QUERY,
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY,
HOST_BRIDGE_TEXT_MIME_TYPES,
HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY,
isHostBridgeCapability,
normalizeHostBridgeBadgeCount,
@@ -835,11 +834,7 @@ export async function exportHostAudioFile(
}
}
const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set<HostBridgeImageMimeType>([
'image/png',
'image/jpeg',
'image/webp',
]);
const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set(HOST_BRIDGE_IMAGE_MIME_TYPES);
function normalizeHostImageImportResult(
payload: FileImportImageResult | null | undefined,
@@ -850,7 +845,7 @@ function normalizeHostImageImportResult(
payload.action !== 'selected' &&
payload.action !== 'dropped' &&
payload.action !== 'captured' ||
!HOST_BRIDGE_IMAGE_MIME_TYPES.has(payload.mimeType) ||
!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.base64Data !== 'string' ||
@@ -950,15 +945,8 @@ export async function scanHostQrCode() {
}
}
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
'text/plain',
'text/markdown',
'text/csv',
'application/json',
]);
const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set<HostBridgeDocumentMimeType>(
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
);
const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set(HOST_BRIDGE_TEXT_MIME_TYPES);
const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set(HOST_BRIDGE_DOCUMENT_MIME_TYPES);
function normalizeHostTextImportResult(
payload: FileImportTextResult | null | undefined,
@@ -967,7 +955,7 @@ function normalizeHostTextImportResult(
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' ||
!HOST_BRIDGE_TEXT_MIME_TYPES.has(payload.mimeType) ||
!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.content !== 'string' ||
@@ -1057,13 +1045,7 @@ export async function importHostDocumentFile() {
}
}
const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
]);
const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set(HOST_BRIDGE_AUDIO_MIME_TYPES);
function normalizeHostAudioImportResult(
payload: FileImportAudioResult | null | undefined,
@@ -1072,7 +1054,7 @@ function normalizeHostAudioImportResult(
!payload ||
typeof payload !== 'object' ||
payload.action !== 'selected' ||
!HOST_BRIDGE_AUDIO_MIME_TYPES.has(payload.mimeType) ||
!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
typeof payload.base64Data !== 'string' ||