From ebe73aacebb1159fbdf0a2f5c1578fde6180b11f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 27 Mar 2026 23:18:19 +0100 Subject: [PATCH 1/3] Add precompiled modules prep before installing pods --- packages/build-tools/src/index.ts | 7 + .../build-tools/src/ios/__tests__/pod.test.ts | 69 ++++++++++ packages/build-tools/src/ios/pod.ts | 3 + .../functions/__tests__/installPods.test.ts | 35 +++++ .../src/steps/functions/installPods.ts | 3 + .../__tests__/precompiledModules.test.ts | 93 +++++++++++++ .../src/utils/precompiledModules.ts | 122 ++++++++++++++++++ packages/worker/src/__unit__/env.test.ts | 25 ++++ .../src/__unit__/runtimeEnvironment.test.ts | 10 +- packages/worker/src/build.ts | 9 +- packages/worker/src/config.ts | 4 + packages/worker/src/env.ts | 5 + 12 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 packages/build-tools/src/ios/__tests__/pod.test.ts create mode 100644 packages/build-tools/src/steps/functions/__tests__/installPods.test.ts create mode 100644 packages/build-tools/src/utils/__tests__/precompiledModules.test.ts create mode 100644 packages/build-tools/src/utils/precompiledModules.ts diff --git a/packages/build-tools/src/index.ts b/packages/build-tools/src/index.ts index e6d88b4507..2c97c2cce2 100644 --- a/packages/build-tools/src/index.ts +++ b/packages/build-tools/src/index.ts @@ -19,5 +19,12 @@ export { PackageManager } from './utils/packageManager'; export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs'; export { Hook, runHookIfPresent } from './utils/hooks'; +export { + maybeStartPreparingPrecompiledModules, + PRECOMPILED_MODULES_PATH, + shouldUsePrecompiledDependencies, + startPreparingPrecompiledDependencies, + waitForPrecompiledModulesPreparationAsync, +} from './utils/precompiledModules'; export * from './generic'; diff --git a/packages/build-tools/src/ios/__tests__/pod.test.ts b/packages/build-tools/src/ios/__tests__/pod.test.ts new file mode 100644 index 0000000000..5ad19a4bfd --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/pod.test.ts @@ -0,0 +1,69 @@ +import downloadFile from '@expo/downloader'; +import spawn from '@expo/turtle-spawn'; +import { mkdirp, mkdtemp, remove } from 'fs-extra'; +import * as tar from 'tar'; + +import { createTestIosJob } from '../../__tests__/utils/job'; +import { createMockLogger } from '../../__tests__/utils/logger'; +import { BuildContext } from '../../context'; +import { + startPreparingPrecompiledDependencies, + PRECOMPILED_MODULES_PATH, +} from '../../utils/precompiledModules'; +import { installPods } from '../pod'; + +jest.mock('@expo/downloader'); +jest.mock('fs-extra'); +jest.mock('tar'); +jest.mock('@expo/turtle-spawn'); + +describe(installPods.name, () => { + beforeEach(() => { + jest.mocked(remove).mockImplementation(async () => undefined as any); + jest.mocked(mkdirp).mockImplementation(async () => undefined as any); + jest.mocked(mkdtemp).mockImplementation(async () => '/tmp/precompiled-modules-test' as any); + jest.mocked(tar.extract).mockResolvedValue(undefined); + }); + + it('waits for precompiled dependencies before running pod install', async () => { + let resolvePreparation: (() => void) | undefined; + const preparationPromise = new Promise(resolve => { + resolvePreparation = resolve; + }); + jest.mocked(downloadFile).mockReturnValue(preparationPromise); + const ctx = new BuildContext( + createTestIosJob({ + buildCredentials: undefined, + }), + { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + } + ); + startPreparingPrecompiledDependencies(ctx, 'https://example.com/precompiled-modules.tar.gz'); + + jest.mocked(spawn).mockResolvedValue({} as any); + + const installPodsPromise = installPods(ctx, {}); + await Promise.resolve(); + + expect(spawn).not.toHaveBeenCalled(); + + resolvePreparation?.(); + await installPodsPromise; + + expect(spawn).toHaveBeenCalledWith( + 'pod', + ['install'], + expect.objectContaining({ + cwd: '/workingdir/build/ios', + }) + ); + expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH); + }); +}); diff --git a/packages/build-tools/src/ios/pod.ts b/packages/build-tools/src/ios/pod.ts index 4b79a652d2..8bc225dee8 100644 --- a/packages/build-tools/src/ios/pod.ts +++ b/packages/build-tools/src/ios/pod.ts @@ -3,11 +3,14 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa import path from 'path'; import { BuildContext } from '../context'; +import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules'; export async function installPods( ctx: BuildContext, { infoCallbackFn }: SpawnOptions ): Promise<{ spawnPromise: SpawnPromise }> { + await waitForPrecompiledModulesPreparationAsync(); + const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios'); const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : []; diff --git a/packages/build-tools/src/steps/functions/__tests__/installPods.test.ts b/packages/build-tools/src/steps/functions/__tests__/installPods.test.ts new file mode 100644 index 0000000000..1bcda99d6d --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/installPods.test.ts @@ -0,0 +1,35 @@ +import spawn from '@expo/turtle-spawn'; + +import { createGlobalContextMock } from '../../../__tests__/utils/context'; +import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules'; +import { createInstallPodsBuildFunction } from '../installPods'; + +jest.mock('@expo/turtle-spawn'); +jest.mock('../../../utils/precompiledModules', () => ({ + ...jest.requireActual('../../../utils/precompiledModules'), + waitForPrecompiledModulesPreparationAsync: jest.fn(), +})); + +describe(createInstallPodsBuildFunction, () => { + it('waits for precompiled dependencies before running pod install', async () => { + jest.mocked(spawn).mockResolvedValue({} as any); + jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined); + + const installPods = createInstallPodsBuildFunction(); + const globalContext = createGlobalContextMock({}); + const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, { + callInputs: {}, + }); + + await buildStep.executeAsync(); + + expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'pod', + ['install'], + expect.objectContaining({ + cwd: globalContext.defaultWorkingDirectory, + }) + ); + }); +}); diff --git a/packages/build-tools/src/steps/functions/installPods.ts b/packages/build-tools/src/steps/functions/installPods.ts index 21e5c0ee54..033f67f1b6 100644 --- a/packages/build-tools/src/steps/functions/installPods.ts +++ b/packages/build-tools/src/steps/functions/installPods.ts @@ -1,6 +1,8 @@ import { BuildFunction } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; +import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules'; + export function createInstallPodsBuildFunction(): BuildFunction { return new BuildFunction({ namespace: 'eas', @@ -8,6 +10,7 @@ export function createInstallPodsBuildFunction(): BuildFunction { name: 'Install Pods', __metricsId: 'eas/install_pods', fn: async (stepsCtx, { env }) => { + await waitForPrecompiledModulesPreparationAsync(); stepsCtx.logger.info('Installing pods'); const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : []; const cocoapodsDeploymentFlag = diff --git a/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts b/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts new file mode 100644 index 0000000000..409bb43b6a --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts @@ -0,0 +1,93 @@ +import downloadFile from '@expo/downloader'; +import { mkdirp, mkdtemp, remove } from 'fs-extra'; +import * as tar from 'tar'; + +import { + PRECOMPILED_MODULES_PATH, + startPreparingPrecompiledDependencies, + waitForPrecompiledModulesPreparationAsync, +} from '../precompiledModules'; +import { createMockLogger } from '../../__tests__/utils/logger'; +import { BuildContext } from '../../context'; + +jest.mock('@expo/downloader'); +jest.mock('fs-extra'); +jest.mock('tar'); + +describe('precompiledModules', () => { + const createCtx = (env: Record = {}): BuildContext => + ({ + logger: createMockLogger(), + env, + }) as unknown as BuildContext; + + beforeEach(() => { + jest.mocked(remove).mockImplementation(async () => undefined as any); + jest.mocked(mkdirp).mockImplementation(async () => undefined as any); + jest.mocked(mkdtemp).mockImplementation(async () => '/tmp/precompiled-modules-test' as any); + jest.mocked(tar.extract).mockResolvedValue(undefined); + jest.mocked(downloadFile).mockReset(); + }); + + it('downloads through CocoaPods proxy when enabled', async () => { + jest.mocked(downloadFile).mockResolvedValue(undefined); + + startPreparingPrecompiledDependencies( + createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }), + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz' + ); + + await waitForPrecompiledModulesPreparationAsync(); + + expect(downloadFile).toHaveBeenCalledWith( + 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', + '/tmp/precompiled-modules-test/precompiled-modules.tar.gz', + { retry: 3 } + ); + }); + + it('falls back to the direct url when CocoaPods proxy download fails', async () => { + jest + .mocked(downloadFile) + .mockRejectedValueOnce(new Error('proxy failed')) + .mockResolvedValueOnce(undefined); + + startPreparingPrecompiledDependencies( + createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }), + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz' + ); + + await waitForPrecompiledModulesPreparationAsync(); + + expect(downloadFile).toHaveBeenNthCalledWith( + 1, + 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', + '/tmp/precompiled-modules-test/precompiled-modules.tar.gz', + { retry: 3 } + ); + expect(downloadFile).toHaveBeenNthCalledWith( + 2, + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', + '/tmp/precompiled-modules-test/precompiled-modules.tar.gz', + { retry: 3 } + ); + }); + + it('uses the well-known destination path', async () => { + jest.mocked(downloadFile).mockResolvedValue(undefined); + + startPreparingPrecompiledDependencies( + createCtx(), + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz' + ); + + await waitForPrecompiledModulesPreparationAsync(); + + expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH); + expect(tar.extract).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: PRECOMPILED_MODULES_PATH, + }) + ); + }); +}); diff --git a/packages/build-tools/src/utils/precompiledModules.ts b/packages/build-tools/src/utils/precompiledModules.ts new file mode 100644 index 0000000000..0bfb61a725 --- /dev/null +++ b/packages/build-tools/src/utils/precompiledModules.ts @@ -0,0 +1,122 @@ +import downloadFile from '@expo/downloader'; +import { bunyan } from '@expo/logger'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import * as tar from 'tar'; + +import { BuildContext } from '../context'; + +let precompiledModulesPreparationPromise: Promise | null = null; +export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules'); + +export function shouldUsePrecompiledDependencies(env: Record): boolean { + return env.EAS_USE_PRECOMPILED_MODULES === '1'; +} + +export function maybeStartPreparingPrecompiledModules( + ctx: BuildContext, + config: { precompiledModulesUrl: string } +): void { + if (!shouldUsePrecompiledDependencies(ctx.env)) { + return; + } + + startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrl); +} + +export function startPreparingPrecompiledDependencies(ctx: BuildContext, url: string): void { + precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({ + logger: ctx.logger, + url, + destinationDirectory: PRECOMPILED_MODULES_PATH, + cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL, + }); +} + +export async function waitForPrecompiledModulesPreparationAsync(): Promise { + await precompiledModulesPreparationPromise; +} + +async function preparePrecompiledDependenciesAsync({ + logger, + url, + destinationDirectory, + cocoapodsProxyUrl, +}: { + logger: bunyan; + url: string; + destinationDirectory: string; + cocoapodsProxyUrl?: string; +}): Promise { + const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-')); + const archivePath = path.join(archiveDirectory, 'precompiled-modules.tar.gz'); + + logger.info( + { + destinationDirectory, + url, + }, + 'Starting precompiled dependencies download' + ); + + await fs.remove(destinationDirectory); + await fs.mkdirp(destinationDirectory); + + try { + await downloadPrecompiledModulesAsync({ + url, + archivePath, + cocoapodsProxyUrl, + logger, + }); + logger.info({ archivePath }, 'Downloaded precompiled dependencies, extracting archive'); + await tar.extract({ + file: archivePath, + cwd: destinationDirectory, + }); + logger.info({ destinationDirectory }, 'Prepared precompiled dependencies'); + } finally { + await fs.remove(archiveDirectory); + } +} + +async function downloadPrecompiledModulesAsync({ + url, + archivePath, + cocoapodsProxyUrl, + logger, +}: { + url: string; + archivePath: string; + cocoapodsProxyUrl?: string; + logger: bunyan; +}): Promise { + const proxiedUrl = cocoapodsProxyUrl + ? rewriteUrlThroughCocoapodsProxy({ url, cocoapodsProxyUrl }) + : null; + if (proxiedUrl) { + try { + await downloadFile(proxiedUrl, archivePath, { retry: 3 }); + return; + } catch (err) { + logger.warn( + { err, proxiedUrl }, + 'Failed to download through CocoaPods proxy, retrying directly' + ); + } + } + + await downloadFile(url, archivePath, { retry: 3 }); +} + +function rewriteUrlThroughCocoapodsProxy({ + url, + cocoapodsProxyUrl, +}: { + url: string; + cocoapodsProxyUrl: string; +}): string { + const parsedUrl = new URL(url); + return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; +} diff --git a/packages/worker/src/__unit__/env.test.ts b/packages/worker/src/__unit__/env.test.ts index 05b69ed0e5..0167688f31 100644 --- a/packages/worker/src/__unit__/env.test.ts +++ b/packages/worker/src/__unit__/env.test.ts @@ -1,3 +1,4 @@ +import { PRECOMPILED_MODULES_PATH } from '@expo/build-tools'; import { Platform, Workflow } from '@expo/eas-build-job'; import config from '../config'; @@ -33,4 +34,28 @@ describe(getBuildEnv.name, () => { expect(env.EAS_CLI_SENTRY_DSN).toBe(config.sentry.dsn); }); + + it('enables precompiled modules env vars for flagged iOS jobs', () => { + const env = getBuildEnv({ + job: { + platform: Platform.IOS, + type: Workflow.GENERIC, + builderEnvironment: { + env: { + EAS_USE_PRECOMPILED_MODULES: '1', + }, + }, + } as any, + projectId: 'project-id', + metadata: { + buildProfile: 'production', + gitCommitHash: 'abc123', + username: 'expo-user', + } as any, + buildId: 'build-id', + }); + + expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1'); + expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBe(PRECOMPILED_MODULES_PATH); + }); }); diff --git a/packages/worker/src/__unit__/runtimeEnvironment.test.ts b/packages/worker/src/__unit__/runtimeEnvironment.test.ts index ad1fd429aa..951766d076 100644 --- a/packages/worker/src/__unit__/runtimeEnvironment.test.ts +++ b/packages/worker/src/__unit__/runtimeEnvironment.test.ts @@ -1,9 +1,7 @@ // @ts-nocheck -import { BuildContext } from '@expo/build-tools'; import { Android, Ios, Job } from '@expo/eas-build-job'; import spawn, { SpawnResult } from '@expo/turtle-spawn'; import { pathExists } from 'fs-extra'; - import { prepareRuntimeEnvironment } from '../runtimeEnvironment'; jest.mock('fs-extra'); @@ -16,8 +14,12 @@ const spawnResult: SpawnResult = { stdout: '', stderr: '', }; -const ctx: BuildContext = { - env: process.env, +const ctx = { + workingdir: '/tmp/workingdir', + env: { + ...process.env, + EAS_BUILD_ID: 'build-id', + }, logger: { info: jest.fn(), error: jest.fn(), diff --git a/packages/worker/src/build.ts b/packages/worker/src/build.ts index 51ea30a827..b00b409e3e 100644 --- a/packages/worker/src/build.ts +++ b/packages/worker/src/build.ts @@ -1,4 +1,10 @@ -import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools'; +import { + Artifacts, + BuildContext, + Builders, + maybeStartPreparingPrecompiledModules, + runGenericJobAsync, +} from '@expo/build-tools'; import { Android, BuildJob, @@ -41,6 +47,7 @@ export async function build({ { job: omit(ctx.job, 'secrets', 'projectArchive') }, 'Builder is ready, starting build' ); + maybeStartPreparingPrecompiledModules(ctx, config); }, { doNotMarkStart: true } ); diff --git a/packages/worker/src/config.ts b/packages/worker/src/config.ts index cf7ccd9cb1..7fbeb73094 100644 --- a/packages/worker/src/config.ts +++ b/packages/worker/src/config.ts @@ -98,6 +98,10 @@ export default { transform: createBase64EnvTransformer('cocoapodsCacheUrl'), defaultValue: null, }), + precompiledModulesUrl: + process.env.ENVIRONMENT === 'staging' + ? 'https://storage.googleapis.com/eas-build-precompiled-modules-staging/ios/precompiled-modules.tar.gz' + : 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', runMetricsServer: env('WORKER_RUNTIME_CONFIG_BASE64', { transform: createBase64EnvTransformer('runMetricsServer'), defaultValue: null, diff --git a/packages/worker/src/env.ts b/packages/worker/src/env.ts index f5b2f4978d..abb8e39b58 100644 --- a/packages/worker/src/env.ts +++ b/packages/worker/src/env.ts @@ -1,3 +1,4 @@ +import { PRECOMPILED_MODULES_PATH, shouldUsePrecompiledDependencies } from '@expo/build-tools'; import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job'; import { spawnSync } from 'child_process'; import micromatch from 'micromatch'; @@ -53,6 +54,10 @@ export function getBuildEnv({ if (runnerPlatform === Platform.IOS) { setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl); setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO'); + if (shouldUsePrecompiledDependencies(job.builderEnvironment?.env ?? {})) { + setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1'); + setEnv(env, 'EXPO_PRECOMPILED_MODULES_PATH', PRECOMPILED_MODULES_PATH); + } if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') { setEnv(env, 'USE_CCACHE', '1'); From 75c3621f1fad3a55419b6b61e762b719a41c57e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 28 Mar 2026 21:59:13 +0100 Subject: [PATCH 2/3] Add zip-based precompiled module downloads and merge test --- .../build-tools/src/ios/__tests__/pod.test.ts | 27 +++- .../precompiledModules/xcframeworks-Debug.zip | Bin 0 -> 2026 bytes .../xcframeworks-Release.zip | Bin 0 -> 2115 bytes .../precompiledModules.test.ts | 120 ++++++++++++++++++ .../__tests__/precompiledModules.test.ts | 69 +++++++--- .../src/utils/precompiledModules.ts | 68 +++++----- packages/worker/src/config.ts | 8 +- 7 files changed, 235 insertions(+), 57 deletions(-) create mode 100644 packages/build-tools/src/utils/__integration-tests__/fixtures/precompiledModules/xcframeworks-Debug.zip create mode 100644 packages/build-tools/src/utils/__integration-tests__/fixtures/precompiledModules/xcframeworks-Release.zip create mode 100644 packages/build-tools/src/utils/__integration-tests__/precompiledModules.test.ts diff --git a/packages/build-tools/src/ios/__tests__/pod.test.ts b/packages/build-tools/src/ios/__tests__/pod.test.ts index 5ad19a4bfd..268084874f 100644 --- a/packages/build-tools/src/ios/__tests__/pod.test.ts +++ b/packages/build-tools/src/ios/__tests__/pod.test.ts @@ -1,7 +1,7 @@ import downloadFile from '@expo/downloader'; import spawn from '@expo/turtle-spawn'; import { mkdirp, mkdtemp, remove } from 'fs-extra'; -import * as tar from 'tar'; +import StreamZip from 'node-stream-zip'; import { createTestIosJob } from '../../__tests__/utils/job'; import { createMockLogger } from '../../__tests__/utils/logger'; @@ -14,15 +14,31 @@ import { installPods } from '../pod'; jest.mock('@expo/downloader'); jest.mock('fs-extra'); -jest.mock('tar'); jest.mock('@expo/turtle-spawn'); +jest.mock('node-stream-zip', () => ({ + __esModule: true, + default: { + async: jest.fn(), + }, +})); describe(installPods.name, () => { + const extract = jest.fn(); + const close = jest.fn(); + beforeEach(() => { jest.mocked(remove).mockImplementation(async () => undefined as any); jest.mocked(mkdirp).mockImplementation(async () => undefined as any); jest.mocked(mkdtemp).mockImplementation(async () => '/tmp/precompiled-modules-test' as any); - jest.mocked(tar.extract).mockResolvedValue(undefined); + extract.mockReset().mockResolvedValue(undefined); + close.mockReset().mockResolvedValue(undefined); + jest.mocked(StreamZip.async).mockImplementation( + () => + ({ + extract, + close, + }) as any + ); }); it('waits for precompiled dependencies before running pod install', async () => { @@ -45,7 +61,10 @@ describe(installPods.name, () => { uploadArtifact: jest.fn(), } ); - startPreparingPrecompiledDependencies(ctx, 'https://example.com/precompiled-modules.tar.gz'); + startPreparingPrecompiledDependencies(ctx, [ + 'https://example.com/precompiled-modules-0.zip', + 'https://example.com/precompiled-modules-1.zip', + ]); jest.mocked(spawn).mockResolvedValue({} as any); diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/precompiledModules/xcframeworks-Debug.zip b/packages/build-tools/src/utils/__integration-tests__/fixtures/precompiledModules/xcframeworks-Debug.zip new file mode 100644 index 0000000000000000000000000000000000000000..8546138fb42b4042208b21a259882b9e3f99b7a2 GIT binary patch literal 2026 zcmWIWW@h1H0D`_$-r#na3T|gODnh;7+JnD zGBB`+0M!TJG({NAl>E|?g3=N)4U@!fSW0SAX*!uks^K%TA~~%H==t*eqU>T4%-_Ie zD<}mF6%aNj+I&}FSUZD*TCXIrNH4t#Ux0(Xtbi0f3Tc@YC8b5FT)3k`2nGQVFqLq-Ox;g zrv%asmP9dlGIoPu>49|P)liIohR=AkR6(3wsF@!UM!;f{VM(JKkU|c1l%$Pf7btC@ z6$Qju4zvdjgit(s5oS4Z(jhD2QR7FJ3F;{nb18^^c@$?C;4_~5M4*FW$w|VNphOR` q*#)(DKu!w!%y>c#;w=;#aTON0QUxm;C>d}8Atwt1!)c&x3=9C9{oQW> literal 0 HcmV?d00001 diff --git a/packages/build-tools/src/utils/__integration-tests__/fixtures/precompiledModules/xcframeworks-Release.zip b/packages/build-tools/src/utils/__integration-tests__/fixtures/precompiledModules/xcframeworks-Release.zip new file mode 100644 index 0000000000000000000000000000000000000000..0774ec6faa563fdd995e6102e806d727c083a833 GIT binary patch literal 2115 zcmWIWW@h1H0D_?EoLG{XpQj%h!pXpFT4tjXivx=RrlP$29`sx__=j)-{BD@iQUORvHg8_-ZtK#xL&w9JZ<(xOx@+|eZj zb6akHN@-4Nv2JpH5xK!BiPbEM;$9J-k(5Qg9x+Cf8T(9E5*H}}V;+QENVdlnm=k>= z(cuh?4N?;Ta!ewn07fP`W?Z>n0_aW<5MX%g2%^yw5-{PjLdpV+asaon5)8=3dSf#d zo;FA~T>-`Pxp+)RBooqY(M7T4I}x^^r5NI@L@g~K!3Hd98J0B00V#~wfCd}PV<=W) zFFS~H2GB-05JK_!FPJls6Bk*jLK4MXHzug}P|T$uStz0yKNFwvu;3#nZR?>}^Mx2| uP-2N#J5dV~zyJXI{RZCv literal 0 HcmV?d00001 diff --git a/packages/build-tools/src/utils/__integration-tests__/precompiledModules.test.ts b/packages/build-tools/src/utils/__integration-tests__/precompiledModules.test.ts new file mode 100644 index 0000000000..8ef2767c34 --- /dev/null +++ b/packages/build-tools/src/utils/__integration-tests__/precompiledModules.test.ts @@ -0,0 +1,120 @@ +import fs from 'fs-extra'; +import nock from 'nock'; +import os from 'os'; +import path from 'path'; + +import { createMockLogger } from '../../__tests__/utils/logger'; + +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +describe('precompiledModules integration', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.enableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('merges the extracted debug and release xcframework trees', async () => { + const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-')); + const previousHome = process.env.HOME; + process.env.HOME = homeDirectory; + + try { + const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules'); + const debugArchive = await fs.readFile( + path.join(fixturesDirectory, 'xcframeworks-Debug.zip') + ); + const releaseArchive = await fs.readFile( + path.join(fixturesDirectory, 'xcframeworks-Release.zip') + ); + + nock('https://fixtures.expo.test') + .get('/xcframeworks-Debug.zip') + .reply(200, debugArchive, { 'Content-Type': 'application/zip' }) + .get('/xcframeworks-Release.zip') + .reply(200, releaseArchive, { 'Content-Type': 'application/zip' }); + + jest.resetModules(); + jest.doMock('os', () => ({ + ...jest.requireActual('os'), + homedir: () => homeDirectory, + })); + const { + PRECOMPILED_MODULES_PATH, + startPreparingPrecompiledDependencies, + waitForPrecompiledModulesPreparationAsync, + } = await import('../precompiledModules'); + + startPreparingPrecompiledDependencies( + { + logger: createMockLogger(), + env: {}, + } as any, + [ + 'https://fixtures.expo.test/xcframeworks-Debug.zip', + 'https://fixtures.expo.test/xcframeworks-Release.zip', + ] + ); + await waitForPrecompiledModulesPreparationAsync(); + + expect(PRECOMPILED_MODULES_PATH).toBe( + path.join(homeDirectory, '.expo', 'precompiled-modules') + ); + + await expect( + fs.readFile( + path.join( + PRECOMPILED_MODULES_PATH, + 'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz' + ), + 'utf8' + ) + ).resolves.toBe('debug expo-modules-core fixture\n'); + await expect( + fs.readFile( + path.join( + PRECOMPILED_MODULES_PATH, + 'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz' + ), + 'utf8' + ) + ).resolves.toBe('release expo-modules-core fixture\n'); + await expect( + fs.readFile( + path.join( + PRECOMPILED_MODULES_PATH, + 'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz' + ), + 'utf8' + ) + ).resolves.toBe('debug expo-camera fixture\n'); + await expect( + fs.readFile( + path.join( + PRECOMPILED_MODULES_PATH, + 'expo-application/output/release/xcframeworks/EXApplication.tar.gz' + ), + 'utf8' + ) + ).resolves.toBe('release expo-application fixture\n'); + } finally { + jest.dontMock('os'); + jest.resetModules(); + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.remove(homeDirectory); + } + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts b/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts index 409bb43b6a..076b60b358 100644 --- a/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts +++ b/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts @@ -1,6 +1,6 @@ import downloadFile from '@expo/downloader'; import { mkdirp, mkdtemp, remove } from 'fs-extra'; -import * as tar from 'tar'; +import StreamZip from 'node-stream-zip'; import { PRECOMPILED_MODULES_PATH, @@ -12,9 +12,17 @@ import { BuildContext } from '../../context'; jest.mock('@expo/downloader'); jest.mock('fs-extra'); -jest.mock('tar'); +jest.mock('node-stream-zip', () => ({ + __esModule: true, + default: { + async: jest.fn(), + }, +})); describe('precompiledModules', () => { + const extract = jest.fn(); + const close = jest.fn(); + const createCtx = (env: Record = {}): BuildContext => ({ logger: createMockLogger(), @@ -25,8 +33,16 @@ describe('precompiledModules', () => { jest.mocked(remove).mockImplementation(async () => undefined as any); jest.mocked(mkdirp).mockImplementation(async () => undefined as any); jest.mocked(mkdtemp).mockImplementation(async () => '/tmp/precompiled-modules-test' as any); - jest.mocked(tar.extract).mockResolvedValue(undefined); jest.mocked(downloadFile).mockReset(); + extract.mockReset().mockResolvedValue(undefined); + close.mockReset().mockResolvedValue(undefined); + jest.mocked(StreamZip.async).mockImplementation( + () => + ({ + extract, + close, + }) as any + ); }); it('downloads through CocoaPods proxy when enabled', async () => { @@ -34,14 +50,16 @@ describe('precompiledModules', () => { startPreparingPrecompiledDependencies( createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }), - 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz' + [ + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + ] ); await waitForPrecompiledModulesPreparationAsync(); expect(downloadFile).toHaveBeenCalledWith( - 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', - '/tmp/precompiled-modules-test/precompiled-modules.tar.gz', + 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + '/tmp/precompiled-modules-test/precompiled-modules-0.zip', { retry: 3 } ); }); @@ -54,40 +72,51 @@ describe('precompiledModules', () => { startPreparingPrecompiledDependencies( createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }), - 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz' + [ + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + ] ); await waitForPrecompiledModulesPreparationAsync(); expect(downloadFile).toHaveBeenNthCalledWith( 1, - 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', - '/tmp/precompiled-modules-test/precompiled-modules.tar.gz', + 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + '/tmp/precompiled-modules-test/precompiled-modules-0.zip', { retry: 3 } ); expect(downloadFile).toHaveBeenNthCalledWith( 2, - 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', - '/tmp/precompiled-modules-test/precompiled-modules.tar.gz', + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + '/tmp/precompiled-modules-test/precompiled-modules-0.zip', { retry: 3 } ); }); - it('uses the well-known destination path', async () => { + it('extracts all configured archives into the well-known destination path', async () => { jest.mocked(downloadFile).mockResolvedValue(undefined); - startPreparingPrecompiledDependencies( - createCtx(), - 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz' - ); + startPreparingPrecompiledDependencies(createCtx(), [ + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip', + ]); await waitForPrecompiledModulesPreparationAsync(); expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH); - expect(tar.extract).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: PRECOMPILED_MODULES_PATH, - }) + expect(downloadFile).toHaveBeenNthCalledWith( + 1, + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + '/tmp/precompiled-modules-test/precompiled-modules-0.zip', + { retry: 3 } + ); + expect(downloadFile).toHaveBeenNthCalledWith( + 2, + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip', + '/tmp/precompiled-modules-test/precompiled-modules-1.zip', + { retry: 3 } ); + expect(extract).toHaveBeenNthCalledWith(1, null, PRECOMPILED_MODULES_PATH); + expect(extract).toHaveBeenNthCalledWith(2, null, PRECOMPILED_MODULES_PATH); }); }); diff --git a/packages/build-tools/src/utils/precompiledModules.ts b/packages/build-tools/src/utils/precompiledModules.ts index 0bfb61a725..d1c214c9a4 100644 --- a/packages/build-tools/src/utils/precompiledModules.ts +++ b/packages/build-tools/src/utils/precompiledModules.ts @@ -3,7 +3,7 @@ import { bunyan } from '@expo/logger'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import * as tar from 'tar'; +import StreamZip from 'node-stream-zip'; import { BuildContext } from '../context'; @@ -16,19 +16,19 @@ export function shouldUsePrecompiledDependencies(env: Record async function preparePrecompiledDependenciesAsync({ logger, - url, + urls, destinationDirectory, cocoapodsProxyUrl, }: { logger: bunyan; - url: string; + urls: string[]; destinationDirectory: string; cocoapodsProxyUrl?: string; }): Promise { const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-')); - const archivePath = path.join(archiveDirectory, 'precompiled-modules.tar.gz'); logger.info( { destinationDirectory, - url, + urls, }, 'Starting precompiled dependencies download' ); @@ -64,17 +63,27 @@ async function preparePrecompiledDependenciesAsync({ await fs.mkdirp(destinationDirectory); try { - await downloadPrecompiledModulesAsync({ + const archives = urls.map((url, index) => ({ url, - archivePath, - cocoapodsProxyUrl, - logger, - }); - logger.info({ archivePath }, 'Downloaded precompiled dependencies, extracting archive'); - await tar.extract({ - file: archivePath, - cwd: destinationDirectory, - }); + archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`), + })); + + await Promise.all( + archives.map(async ({ url, archivePath }) => { + await downloadPrecompiledModulesAsync({ + url, + archivePath, + cocoapodsProxyUrl, + logger, + }); + }) + ); + + for (const { url, archivePath } of archives) { + logger.info({ archivePath, url }, 'Downloaded precompiled dependencies, extracting archive'); + await extractZipAsync(archivePath, destinationDirectory); + } + logger.info({ destinationDirectory }, 'Prepared precompiled dependencies'); } finally { await fs.remove(archiveDirectory); @@ -93,7 +102,10 @@ async function downloadPrecompiledModulesAsync({ logger: bunyan; }): Promise { const proxiedUrl = cocoapodsProxyUrl - ? rewriteUrlThroughCocoapodsProxy({ url, cocoapodsProxyUrl }) + ? (() => { + const parsedUrl = new URL(url); + return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; + })() : null; if (proxiedUrl) { try { @@ -110,13 +122,11 @@ async function downloadPrecompiledModulesAsync({ await downloadFile(url, archivePath, { retry: 3 }); } -function rewriteUrlThroughCocoapodsProxy({ - url, - cocoapodsProxyUrl, -}: { - url: string; - cocoapodsProxyUrl: string; -}): string { - const parsedUrl = new URL(url); - return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; +async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise { + const zip = new StreamZip.async({ file: archivePath }); + try { + await zip.extract(null, destinationDirectory); + } finally { + await zip.close(); + } } diff --git a/packages/worker/src/config.ts b/packages/worker/src/config.ts index 7fbeb73094..71c1968caf 100644 --- a/packages/worker/src/config.ts +++ b/packages/worker/src/config.ts @@ -98,10 +98,10 @@ export default { transform: createBase64EnvTransformer('cocoapodsCacheUrl'), defaultValue: null, }), - precompiledModulesUrl: - process.env.ENVIRONMENT === 'staging' - ? 'https://storage.googleapis.com/eas-build-precompiled-modules-staging/ios/precompiled-modules.tar.gz' - : 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules.tar.gz', + precompiledModulesUrls: [ + 'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Debug.zip', + 'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Release.zip', + ], runMetricsServer: env('WORKER_RUNTIME_CONFIG_BASE64', { transform: createBase64EnvTransformer('runMetricsServer'), defaultValue: null, From 77ef2ed8f19004a6c5659bf9e1dbdad1b958133f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Sat, 28 Mar 2026 22:25:42 +0100 Subject: [PATCH 3/3] Handle precompiled modules timeouts and preparation failures --- .../build-tools/src/ios/__tests__/pod.test.ts | 43 ++++++++++++- packages/build-tools/src/ios/pod.ts | 9 ++- .../functions/__tests__/installPods.test.ts | 24 +++++++ .../src/steps/functions/installPods.ts | 9 ++- .../__tests__/precompiledModules.test.ts | 64 ++++++++++++++++--- .../src/utils/precompiledModules.ts | 52 +++++++++++++-- 6 files changed, 183 insertions(+), 18 deletions(-) diff --git a/packages/build-tools/src/ios/__tests__/pod.test.ts b/packages/build-tools/src/ios/__tests__/pod.test.ts index 268084874f..6dcf49b4f3 100644 --- a/packages/build-tools/src/ios/__tests__/pod.test.ts +++ b/packages/build-tools/src/ios/__tests__/pod.test.ts @@ -1,6 +1,6 @@ import downloadFile from '@expo/downloader'; import spawn from '@expo/turtle-spawn'; -import { mkdirp, mkdtemp, remove } from 'fs-extra'; +import { mkdirp, mkdtemp, move, remove } from 'fs-extra'; import StreamZip from 'node-stream-zip'; import { createTestIosJob } from '../../__tests__/utils/job'; @@ -29,7 +29,14 @@ describe(installPods.name, () => { beforeEach(() => { jest.mocked(remove).mockImplementation(async () => undefined as any); jest.mocked(mkdirp).mockImplementation(async () => undefined as any); - jest.mocked(mkdtemp).mockImplementation(async () => '/tmp/precompiled-modules-test' as any); + jest.mocked(move).mockImplementation(async () => undefined as any); + let mkdtempCallCount = 0; + jest.mocked(mkdtemp).mockImplementation(async () => { + mkdtempCallCount += 1; + return mkdtempCallCount === 1 + ? ('/tmp/precompiled-modules-archive' as any) + : ('/tmp/precompiled-modules-staging' as any); + }); extract.mockReset().mockResolvedValue(undefined); close.mockReset().mockResolvedValue(undefined); jest.mocked(StreamZip.async).mockImplementation( @@ -85,4 +92,36 @@ describe(installPods.name, () => { ); expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH); }); + + it('continues with pod install when precompiled dependencies preparation fails', async () => { + jest.mocked(downloadFile).mockRejectedValue(new Error('download failed')); + const ctx = new BuildContext( + createTestIosJob({ + buildCredentials: undefined, + }), + { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + } + ); + startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']); + + jest.mocked(spawn).mockResolvedValue({} as any); + + await expect(installPods(ctx, {})).resolves.toEqual({ + spawnPromise: expect.any(Promise), + }); + expect(spawn).toHaveBeenCalledWith( + 'pod', + ['install'], + expect.objectContaining({ + cwd: '/workingdir/build/ios', + }) + ); + }); }); diff --git a/packages/build-tools/src/ios/pod.ts b/packages/build-tools/src/ios/pod.ts index 8bc225dee8..1f94cab840 100644 --- a/packages/build-tools/src/ios/pod.ts +++ b/packages/build-tools/src/ios/pod.ts @@ -9,7 +9,14 @@ export async function installPods( ctx: BuildContext, { infoCallbackFn }: SpawnOptions ): Promise<{ spawnPromise: SpawnPromise }> { - await waitForPrecompiledModulesPreparationAsync(); + try { + await waitForPrecompiledModulesPreparationAsync(); + } catch (err) { + ctx.logger.warn( + { err }, + 'Precompiled dependencies were not prepared successfully, continuing with pod install' + ); + } const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios'); diff --git a/packages/build-tools/src/steps/functions/__tests__/installPods.test.ts b/packages/build-tools/src/steps/functions/__tests__/installPods.test.ts index 1bcda99d6d..fa2bd738a8 100644 --- a/packages/build-tools/src/steps/functions/__tests__/installPods.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/installPods.test.ts @@ -32,4 +32,28 @@ describe(createInstallPodsBuildFunction, () => { }) ); }); + + it('continues with pod install when precompiled dependencies preparation fails', async () => { + jest.mocked(spawn).mockResolvedValue({} as any); + jest + .mocked(waitForPrecompiledModulesPreparationAsync) + .mockRejectedValue(new Error('precompiled dependencies failed')); + + const installPods = createInstallPodsBuildFunction(); + const globalContext = createGlobalContextMock({}); + const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, { + callInputs: {}, + }); + + await expect(buildStep.executeAsync()).resolves.toBeUndefined(); + + expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'pod', + ['install'], + expect.objectContaining({ + cwd: globalContext.defaultWorkingDirectory, + }) + ); + }); }); diff --git a/packages/build-tools/src/steps/functions/installPods.ts b/packages/build-tools/src/steps/functions/installPods.ts index 033f67f1b6..2c0a99b28e 100644 --- a/packages/build-tools/src/steps/functions/installPods.ts +++ b/packages/build-tools/src/steps/functions/installPods.ts @@ -10,7 +10,14 @@ export function createInstallPodsBuildFunction(): BuildFunction { name: 'Install Pods', __metricsId: 'eas/install_pods', fn: async (stepsCtx, { env }) => { - await waitForPrecompiledModulesPreparationAsync(); + try { + await waitForPrecompiledModulesPreparationAsync(); + } catch (err) { + stepsCtx.logger.warn( + { err }, + 'Precompiled dependencies were not prepared successfully, continuing with pod install' + ); + } stepsCtx.logger.info('Installing pods'); const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : []; const cocoapodsDeploymentFlag = diff --git a/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts b/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts index 076b60b358..d48a371d4a 100644 --- a/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts +++ b/packages/build-tools/src/utils/__tests__/precompiledModules.test.ts @@ -1,5 +1,5 @@ import downloadFile from '@expo/downloader'; -import { mkdirp, mkdtemp, remove } from 'fs-extra'; +import { mkdirp, mkdtemp, move, remove } from 'fs-extra'; import StreamZip from 'node-stream-zip'; import { @@ -22,6 +22,7 @@ jest.mock('node-stream-zip', () => ({ describe('precompiledModules', () => { const extract = jest.fn(); const close = jest.fn(); + const getMkdtempPath = jest.fn(); const createCtx = (env: Record = {}): BuildContext => ({ @@ -32,7 +33,12 @@ describe('precompiledModules', () => { beforeEach(() => { jest.mocked(remove).mockImplementation(async () => undefined as any); jest.mocked(mkdirp).mockImplementation(async () => undefined as any); - jest.mocked(mkdtemp).mockImplementation(async () => '/tmp/precompiled-modules-test' as any); + jest.mocked(move).mockImplementation(async () => undefined as any); + getMkdtempPath.mockReset(); + getMkdtempPath + .mockReturnValueOnce('/tmp/precompiled-modules-archive') + .mockReturnValueOnce('/tmp/precompiled-modules-staging'); + jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any); jest.mocked(downloadFile).mockReset(); extract.mockReset().mockResolvedValue(undefined); close.mockReset().mockResolvedValue(undefined); @@ -45,6 +51,10 @@ describe('precompiledModules', () => { ); }); + afterEach(() => { + jest.useRealTimers(); + }); + it('downloads through CocoaPods proxy when enabled', async () => { jest.mocked(downloadFile).mockResolvedValue(undefined); @@ -59,7 +69,7 @@ describe('precompiledModules', () => { expect(downloadFile).toHaveBeenCalledWith( 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', - '/tmp/precompiled-modules-test/precompiled-modules-0.zip', + '/tmp/precompiled-modules-archive/precompiled-modules-0.zip', { retry: 3 } ); }); @@ -82,13 +92,13 @@ describe('precompiledModules', () => { expect(downloadFile).toHaveBeenNthCalledWith( 1, 'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', - '/tmp/precompiled-modules-test/precompiled-modules-0.zip', + '/tmp/precompiled-modules-archive/precompiled-modules-0.zip', { retry: 3 } ); expect(downloadFile).toHaveBeenNthCalledWith( 2, 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', - '/tmp/precompiled-modules-test/precompiled-modules-0.zip', + '/tmp/precompiled-modules-archive/precompiled-modules-0.zip', { retry: 3 } ); }); @@ -107,16 +117,52 @@ describe('precompiledModules', () => { expect(downloadFile).toHaveBeenNthCalledWith( 1, 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', - '/tmp/precompiled-modules-test/precompiled-modules-0.zip', + '/tmp/precompiled-modules-archive/precompiled-modules-0.zip', { retry: 3 } ); expect(downloadFile).toHaveBeenNthCalledWith( 2, 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip', - '/tmp/precompiled-modules-test/precompiled-modules-1.zip', + '/tmp/precompiled-modules-archive/precompiled-modules-1.zip', { retry: 3 } ); - expect(extract).toHaveBeenNthCalledWith(1, null, PRECOMPILED_MODULES_PATH); - expect(extract).toHaveBeenNthCalledWith(2, null, PRECOMPILED_MODULES_PATH); + expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging'); + expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging'); + expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH); + }); + + it('leaves the destination empty when preparation fails', async () => { + jest.mocked(downloadFile).mockResolvedValue(undefined); + extract.mockRejectedValueOnce(new Error('extract failed')); + + startPreparingPrecompiledDependencies(createCtx(), [ + 'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip', + ]); + + await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed'); + + expect(move).not.toHaveBeenCalled(); + expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging'); + expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH); + expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH); + }); + + it('throws when precompiled dependencies are still not ready after 30 seconds', async () => { + jest.useFakeTimers(); + jest.mocked(downloadFile).mockImplementation(() => new Promise(() => {}) as any); + + startPreparingPrecompiledDependencies(createCtx(), [ + 'https://example.com/xcframeworks-Debug.zip', + ]); + + const waitPromise = waitForPrecompiledModulesPreparationAsync(); + const waitExpectation = expect(waitPromise).rejects.toThrow( + 'Timed out waiting for precompiled dependencies after 30 seconds' + ); + + await Promise.resolve(); + + await jest.advanceTimersByTimeAsync(30_000); + await waitExpectation; }); }); diff --git a/packages/build-tools/src/utils/precompiledModules.ts b/packages/build-tools/src/utils/precompiledModules.ts index d1c214c9a4..7c978b1fbb 100644 --- a/packages/build-tools/src/utils/precompiledModules.ts +++ b/packages/build-tools/src/utils/precompiledModules.ts @@ -8,6 +8,7 @@ import StreamZip from 'node-stream-zip'; import { BuildContext } from '../context'; let precompiledModulesPreparationPromise: Promise | null = null; +const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000; export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules'); export function shouldUsePrecompiledDependencies(env: Record): boolean { @@ -35,7 +36,30 @@ export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: s } export async function waitForPrecompiledModulesPreparationAsync(): Promise { - await precompiledModulesPreparationPromise; + if (!precompiledModulesPreparationPromise) { + return; + } + + let timeoutHandle: ReturnType | undefined; + try { + await Promise.race([ + precompiledModulesPreparationPromise, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject( + new Error( + `Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds` + ) + ); + }, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS); + timeoutHandle.unref?.(); + }), + ]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } } async function preparePrecompiledDependenciesAsync({ @@ -50,7 +74,13 @@ async function preparePrecompiledDependenciesAsync({ cocoapodsProxyUrl?: string; }): Promise { const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-')); + const destinationParentDirectory = path.dirname(destinationDirectory); + await fs.mkdirp(destinationParentDirectory); + const stagingDirectory = await fs.mkdtemp( + path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`) + ); + logger.info(''); logger.info( { destinationDirectory, @@ -64,6 +94,7 @@ async function preparePrecompiledDependenciesAsync({ try { const archives = urls.map((url, index) => ({ + archiveName: path.basename(new URL(url).pathname), url, archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`), })); @@ -79,12 +110,23 @@ async function preparePrecompiledDependenciesAsync({ }) ); - for (const { url, archivePath } of archives) { - logger.info({ archivePath, url }, 'Downloaded precompiled dependencies, extracting archive'); - await extractZipAsync(archivePath, destinationDirectory); + for (const { archiveName, url, archivePath } of archives) { + logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`); + await extractZipAsync(archivePath, stagingDirectory); + logger.info( + { destinationDirectory: stagingDirectory }, + `Extracted ${archiveName} into staging precompiled dependencies directory` + ); } - logger.info({ destinationDirectory }, 'Prepared precompiled dependencies'); + await fs.remove(destinationDirectory); + await fs.move(stagingDirectory, destinationDirectory); + logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`); + } catch (error) { + await fs.remove(stagingDirectory); + await fs.remove(destinationDirectory); + await fs.mkdirp(destinationDirectory); + throw error; } finally { await fs.remove(archiveDirectory); }