Files
Genarrative/apps/mobile-shell/scripts/check-expo-export.mjs
T
kdletters 01394ed238 门禁加固与 Windows 可跑性
- 新增 scripts/check-generated-bindings.mjs 并接入 lint 与 ai-game-creator-shell:check:rust:crates:重跑 export_bindings 前后逐字节比对共享契约绑定。
- check:native-shells 的 AGC 边界改为负向门禁:渲染源码不得出现 fetch(/XHR/EventSource/sendBeacon/WebSocket/plugin-http,客户端 capability 不得授予 http:default。
- check-pingora-gateway-smoke 在 Windows 上找 pingora-gateway.exe,并修掉「API 并发保护」先关客户端连接再读响应的竞态。
- mobile-shell 三个 smoke 改成固定命令串启动 npm(绕开 npm.cmd EINVAL 与 DEP0190),desktop-shell 的 stage-release-binary 改用 fileURLToPath 修掉 F:\F:\… 路径拼接。
- vitest.config.ts 白名单补回现役根用例;三份 nginx 模板的 SPA allowlist 补回 /components 与 /design-system。
2026-09-28 14:15:50 +08:00

177 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
const shellRoot = new URL('../', import.meta.url);
const outputRoot = new URL('../.expo-export-smoke/', import.meta.url);
const hostBridgeContractUrl = new URL(
'../../../packages/shared/src/contracts/hostBridge.ts',
import.meta.url,
);
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const platforms = ['android', 'ios'];
const blockedDevelopmentWebUrlPatterns = [
/http:\\?\/\\?\/localhost(?::\d+)?/u,
/http:\\?\/\\?\/127\.0\.0\.1(?::\d+)?/u,
/http:\\?\/\\?\/\[::1\](?::\d+)?/u,
];
function readHostBridgePublicWebUrl() {
const contractSource = fs.readFileSync(hostBridgeContractUrl, 'utf8');
const publicWebUrlMatch = contractSource.match(
/HOST_BRIDGE_PUBLIC_WEB_URL\s*=\s*'([^']+)'/u,
);
const publicWebOriginMatch = contractSource.match(
/HOST_BRIDGE_PUBLIC_WEB_ORIGIN\s*=\s*'([^']+)'/u,
);
if (!publicWebUrlMatch || !publicWebOriginMatch) {
throw new Error('HostBridge public web URL contract is missing');
}
const publicWebUrl = publicWebUrlMatch[1];
const publicWebOrigin = publicWebOriginMatch[1];
if (new URL(publicWebUrl).origin !== publicWebOrigin) {
throw new Error(
'HostBridge public web URL and origin contract must point to the same origin',
);
}
return publicWebUrl;
}
const expectedPublicWebUrl = readHostBridgePublicWebUrl();
const requiredNativeHostContextTokens = [
'native_app',
'expo_mobile',
'hostCapabilities',
'hostVersion',
'bridgeVersion',
];
function runExpoExport(platform) {
const outputDir = `.expo-export-smoke/${platform}`;
// 同 check-expo-config.mjs:Windows 上启动 npm.cmd 必须有 shell,这里用固定命令串,
// 既避开 EINVAL 也避开 Node 对「shell + args 数组」的 DEP0190;串里的平台名来自固定
// 列表、输出目录是相对路径,都不含空格或中文。
const result = spawnSync(
`${npmCommand} exec expo export -- --platform ${platform} --output-dir ${outputDir}`,
{
cwd: shellRoot,
encoding: 'utf8',
stdio: 'pipe',
shell: true,
},
);
if (result.error) {
throw new Error(
`failed to start Expo ${platform} export smoke: ${result.error.message}`,
);
}
if (result.signal) {
throw new Error(
`Expo ${platform} export smoke was terminated by signal ${result.signal}`,
);
}
if ((result.status ?? 0) !== 0) {
process.stdout.write(result.stdout ?? '');
process.stderr.write(result.stderr ?? '');
process.exit(result.status ?? 1);
}
}
function readMetadata(platform) {
const metadataPath = new URL(`${platform}/metadata.json`, outputRoot);
if (!fs.existsSync(metadataPath)) {
throw new Error(`Expo ${platform} export did not produce metadata.json`);
}
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
if (metadata.version !== 0) {
throw new Error(`Expo ${platform} export metadata version drifted`);
}
if (metadata.bundler !== 'metro') {
throw new Error(`Expo ${platform} export must use the Metro bundler`);
}
const fileMetadata = metadata.fileMetadata ?? {};
const metadataPlatforms = Object.keys(fileMetadata);
if (metadataPlatforms.length !== 1 || metadataPlatforms[0] !== platform) {
throw new Error(
`Expo ${platform} export metadata must only include its platform`,
);
}
const platformMetadata = fileMetadata[platform];
if (!Array.isArray(platformMetadata?.assets)) {
throw new Error(
`Expo ${platform} export metadata must include an assets array`,
);
}
const bundlePath = platformMetadata?.bundle;
if (typeof bundlePath !== 'string' || bundlePath.length === 0) {
throw new Error(`Expo ${platform} export metadata is missing bundle path`);
}
return bundlePath;
}
function assertBundle(platform, bundlePath) {
const bundleFile = new URL(`${platform}/${bundlePath}`, outputRoot);
if (!fs.existsSync(bundleFile)) {
throw new Error(`Expo ${platform} bundle is missing: ${bundlePath}`);
}
const stats = fs.statSync(bundleFile);
if (stats.size < 100_000) {
throw new Error(`Expo ${platform} bundle is unexpectedly small`);
}
if (
!bundlePath.startsWith(`_expo/static/js/${platform}/AppEntry-`) ||
!bundlePath.endsWith('.hbc')
) {
throw new Error(`Expo ${platform} bundle path does not target AppEntry`);
}
const bundleSource = fs.readFileSync(bundleFile, 'utf8');
if (!bundleSource.includes(expectedPublicWebUrl)) {
throw new Error(
`Expo ${platform} production bundle must include the shared public web URL`,
);
}
for (const token of requiredNativeHostContextTokens) {
if (!bundleSource.includes(token)) {
throw new Error(
`Expo ${platform} production bundle must include native host context token ${token}`,
);
}
}
for (const blockedPattern of blockedDevelopmentWebUrlPatterns) {
if (blockedPattern.test(bundleSource)) {
throw new Error(
`Expo ${platform} production bundle must not include a local development H5 URL`,
);
}
}
}
fs.rmSync(outputRoot, { recursive: true, force: true });
try {
for (const platform of platforms) {
runExpoExport(platform);
assertBundle(platform, readMetadata(platform));
}
console.log('[mobile-shell:expo-export] OK');
} finally {
fs.rmSync(outputRoot, { recursive: true, force: true });
}