01394ed238
- 新增 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。
177 lines
5.3 KiB
JavaScript
177 lines
5.3 KiB
JavaScript
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 });
|
||
}
|