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}`; const result = spawnSync( npmCommand, [ 'exec', 'expo', 'export', '--', '--platform', platform, '--output-dir', outputDir, ], { cwd: shellRoot, encoding: 'utf8', stdio: 'pipe', }, ); 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}); }