diff --git a/CHANGELOG.md b/CHANGELOG.md index e92e376aff..70578b5bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [eas-cli] Pre-warm bsdiff update patches against the top recent updates and the embedded bundles on a branch, rather than only the second-most-recent update, so more devices are served a diffed patch instead of the full un-diffed update while the on-demand diff computes. ([#3869](https://github.com/expo/eas-cli/pull/3869) by [@jc-expo](https://github.com/jc-expo)) + ### ๐Ÿงน Chores - [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman)) diff --git a/packages/eas-cli/src/update/__tests__/utils-test.ts b/packages/eas-cli/src/update/__tests__/utils-test.ts index f65462f9e1..4914abb971 100644 --- a/packages/eas-cli/src/update/__tests__/utils-test.ts +++ b/packages/eas-cli/src/update/__tests__/utils-test.ts @@ -1,4 +1,27 @@ -import { getPlatformsForGroup, truncateString } from '../utils'; +import { instance, mock } from 'ts-mockito'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import fetch from '../../fetch'; +import { UpdatePublishMutation } from '../../graphql/generated'; +import { AssetQuery } from '../../graphql/queries/AssetQuery'; +import { BranchQuery } from '../../graphql/queries/BranchQuery'; +import { ChannelQuery } from '../../graphql/queries/ChannelQuery'; +import { EmbeddedUpdateQuery } from '../../graphql/queries/EmbeddedUpdateQuery'; +import { getPlatformsForGroup, prewarmDiffingAsync, truncateString } from '../utils'; + +jest.mock('../../fetch'); +jest.mock('../../graphql/queries/AssetQuery'); +jest.mock('../../graphql/queries/BranchQuery'); +jest.mock('../../graphql/queries/ChannelQuery'); +jest.mock('../../graphql/queries/EmbeddedUpdateQuery'); + +// A standard "always true" branch mapping that routes the channel to the given branch. +function branchMappingForBranch(branchId: string): string { + return JSON.stringify({ + version: 0, + data: [{ branchId, branchMappingLogic: 'true' }], + }); +} describe('update utility functions', () => { describe(truncateString, () => { @@ -27,4 +50,129 @@ describe('update utility functions', () => { expect(getPlatformsForGroup(input)).toEqual(`N/A`); }); }); + + describe(prewarmDiffingAsync, () => { + const updateStub: UpdatePublishMutation['updateBranch']['publishUpdateGroups'][number] = { + id: 'new-update-id', + group: 'group-1234', + createdAt: '2026-01-01T00:00:00Z', + runtimeVersion: '1.0.0', + platform: 'ios', + manifestFragment: JSON.stringify({ launchAsset: { storageKey: 'launch-key' } }), + isRollBackToEmbedded: false, + manifestPermalink: 'https://expo.dev/fake/manifest/link', + isGitWorkingTreeDirty: false, + branch: { id: 'branch-1234', name: 'production' }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(fetch).mockResolvedValue({} as any); + }); + + it('warms the top-K recent updates and the embedded bundle', async () => { + const graphqlClient = instance(mock()); + jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue(['r1', 'r2']); + jest + .mocked(AssetQuery.getSignedUrlsAsync) + .mockResolvedValue([{ storageKey: 'launch-key', url: 'https://cdn/asset', headers: {} }]); + jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({ + edges: [ + { + node: { + id: 'channel-1', + name: 'production', + branchMapping: branchMappingForBranch('branch-1234'), + }, + }, + ], + } as any); + jest + .mocked(EmbeddedUpdateQuery.viewPaginatedAsync) + .mockResolvedValue({ edges: [{ cursor: 'c0', node: { id: 'e1' } }] } as any); + + const warmed = await prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub]); + + // The embedded bundle diffs against itself; recent updates fall back to the first embedded id. + expect(warmed).toEqual([ + { requestedUpdateId: 'new-update-id', currentUpdateId: 'e1', embeddedUpdateId: 'e1' }, + { requestedUpdateId: 'new-update-id', currentUpdateId: 'r1', embeddedUpdateId: 'e1' }, + { requestedUpdateId: 'new-update-id', currentUpdateId: 'r2', embeddedUpdateId: 'e1' }, + ]); + // Embedded bundles are restricted server-side to the channel that routes to the branch. + expect( + jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync).mock.calls.map(call => call[1].filter) + ).toContainEqual(expect.objectContaining({ channel: 'production' })); + }); + + it('only pre-warms embedded bundles for channels whose branch mapping routes to the published branch', async () => { + const graphqlClient = instance(mock()); + jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue(['r1']); + jest + .mocked(AssetQuery.getSignedUrlsAsync) + .mockResolvedValue([{ storageKey: 'launch-key', url: 'https://cdn/asset', headers: {} }]); + // 'production' routes to the published branch (branch-1234); 'staging' routes elsewhere. + jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({ + edges: [ + { + node: { + id: 'channel-prod', + name: 'production', + branchMapping: branchMappingForBranch('branch-1234'), + }, + }, + { + node: { + id: 'channel-staging', + name: 'staging', + branchMapping: branchMappingForBranch('other-branch'), + }, + }, + ], + } as any); + jest + .mocked(EmbeddedUpdateQuery.viewPaginatedAsync) + .mockResolvedValue({ edges: [{ cursor: 'c0', node: { id: 'e1' } }] } as any); + + await prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub]); + + // Only the eligible 'production' channel is queried โ€” never 'staging'. + expect(EmbeddedUpdateQuery.viewPaginatedAsync).toHaveBeenCalledTimes(1); + expect( + jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync).mock.calls.map(call => call[1].filter) + ).toEqual([expect.objectContaining({ channel: 'production' })]); + }); + + it('is best-effort: swallows errors and resolves to an empty list', async () => { + const graphqlClient = instance(mock()); + jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockRejectedValue(new Error('boom')); + + await expect(prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub])).resolves.toEqual([]); + }); + + it('returns empty when there are no recent updates on the branch', async () => { + const graphqlClient = instance(mock()); + jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue([]); + + await expect(prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub])).resolves.toEqual([]); + }); + + it('returns empty when there is no signed launch asset URL', async () => { + const graphqlClient = instance(mock()); + jest.mocked(BranchQuery.getUpdateIdsOnBranchAsync).mockResolvedValue(['r1']); + jest.mocked(AssetQuery.getSignedUrlsAsync).mockResolvedValue([]); + + await expect(prewarmDiffingAsync(graphqlClient, 'app-id', [updateStub])).resolves.toEqual([]); + }); + + it('skips updates with no launch asset in the manifest', async () => { + const graphqlClient = instance(mock()); + + await expect( + prewarmDiffingAsync(graphqlClient, 'app-id', [ + { ...updateStub, manifestFragment: JSON.stringify({}) }, + ]) + ).resolves.toEqual([]); + }); + }); }); diff --git a/packages/eas-cli/src/update/utils.ts b/packages/eas-cli/src/update/utils.ts index 7ebbc0a7b0..d8fd783b24 100644 --- a/packages/eas-cli/src/update/utils.ts +++ b/packages/eas-cli/src/update/utils.ts @@ -4,7 +4,9 @@ import chalk from 'chalk'; import dateFormat from 'dateformat'; import semver from 'semver'; +import { getBranchIds, getBranchMapping } from '../channel/branch-mapping'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import fetch from '../fetch'; import { AppPlatform, PartnerActor, @@ -18,7 +20,9 @@ import { } from '../graphql/generated'; import { AssetQuery } from '../graphql/queries/AssetQuery'; import { BranchQuery } from '../graphql/queries/BranchQuery'; -import { learnMore } from '../log'; +import { ChannelQuery } from '../graphql/queries/ChannelQuery'; +import { EmbeddedUpdateQuery } from '../graphql/queries/EmbeddedUpdateQuery'; +import Log, { learnMore } from '../log'; import { RequestedPlatform } from '../platform'; import { getActorDisplayName } from '../user/User'; import groupBy from '../utils/expodash/groupBy'; @@ -287,75 +291,221 @@ export function isBundleDiffingEnabled(exp: ExpoConfig): boolean { return (exp.updates as any)?.enableBsdiffPatchSupport === true; } -// Make authenticated requests to the launch asset URL with diffing headers +// A bsdiff patch that was successfully pre-warmed: from a base update (and its embedded bundle) to +// the newly published `requestedUpdateId`. +interface PrewarmedDiff { + requestedUpdateId: string; + currentUpdateId: string; + embeddedUpdateId: string; +} + +// Upper bound on the number of channels we inspect when resolving which channels route to a +// published update's branch. Apps rarely have more than a handful; this just bounds pathological +// cases for what is a best-effort optimization. +const PREWARM_CHANNELS_LIMIT = 100; + +// Resolve the names of channels whose branch mapping routes to the given branch. The GraphQL API +// has no branch->channels lookup (the mapping lives on the channel), so โ€” as elsewhere in eas-cli +// and on the website โ€” we fetch the channels once and evaluate their branchMapping locally. +async function getChannelNamesForBranchAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + branchId: string +): Promise { + const channels = await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync( + graphqlClient, + { + appId, + first: PREWARM_CHANNELS_LIMIT, + } + ); + return channels.edges + .map(edge => edge.node) + .filter(channel => getBranchIds(getBranchMapping(channel.branchMapping)).includes(branchId)) + .map(channel => channel.name); +} + +// Pre-warm bsdiff patches from a set of base updates to a single newly published update by making +// authenticated HEAD requests to its launch asset url with diffing headers. Best-effort: any +// failure is logged and swallowed so it never blocks a publish. Returns the patches that were +// successfully warmed. +async function prewarmUpdateDiffsAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + update: UpdatePublishMutation['updateBranch']['publishUpdateGroups'][number], + launchAssetKey: string +): Promise { + try { + // Sentinel embedded update id used when a project has no registered embedded bundle to diff + // against. Mirrors the "empty default" the server falls back to. + const DUMMY_EMBEDDED_UPDATE_ID = '00000000-0000-0000-0000-000000000000'; + + // Number of most recent updates on the branch to pre-warm bsdiff patches against. Clients running + // any of these can be served a precomputed patch instead of an on-demand diff. + const PREWARM_RECENT_UPDATES_LIMIT = 5; + + // Number of registered embedded bundles (roughly one per native binary in the field) to pre-warm + // patches against. Each represents a fresh-install scenario. + const PREWARM_EMBEDDED_UPDATES_LIMIT = 2; + + const updatePublishPlatform = update.platform as UpdatePublishPlatform; + const platform = updatePublishPlatformToAppPlatform[updatePublishPlatform]; + + // Baseline: pre-warm patches against the most recent updates on the branch so that clients + // currently running any of them can be served a precomputed bsdiff patch. + const recentUpdateIds = await BranchQuery.getUpdateIdsOnBranchAsync(graphqlClient, { + appId, + branchName: update.branch.name, + platform, + runtimeVersion: update.runtimeVersion, + limit: PREWARM_RECENT_UPDATES_LIMIT, + offset: 1, // skip the current update + }); + Log.debug( + `Found ${recentUpdateIds.length} recent update(s) on branch ${update.branch.name} to diff update ${update.id} against: ${recentUpdateIds.join(', ')}` + ); + + if (recentUpdateIds.length === 0) { + Log.debug(`No recent updates to pre-warm for update ${update.id}, skipping`); + return []; + } + + const signed = await AssetQuery.getSignedUrlsAsync(graphqlClient, update.id, [launchAssetKey]); + const first = signed?.[0]; + if (!first) { + Log.debug(`No signed launch asset URL for update ${update.id}, skipping pre-warming`); + return []; + } + + // Pre-warm the patch from the bundle embedded in the native binary to the new update. + // This is what fresh installs request, so generating it ahead of time avoids an + // expensive on-demand diff. Falls back to the empty/default embedded id when the project has no + // registered embedded bundle. + // + // Embedded bundles are channel-scoped, so only builds whose channel routes (via branch mapping) + // to this update's branch can ever request it. Resolve those channels and let the server filter + // embedded updates down to them โ€” pre-warming any other channel's bundle would be wasted work. + const channelNames = await getChannelNamesForBranchAsync( + graphqlClient, + appId, + update.branch.id + ); + Log.debug( + `Found ${channelNames.length} channel(s) routing to branch ${update.branch.name} for update ${update.id}: ${channelNames.join(', ')}` + ); + + const embeddedUpdateIdSet = new Set(); + for (const channel of channelNames) { + if (embeddedUpdateIdSet.size >= PREWARM_EMBEDDED_UPDATES_LIMIT) { + break; + } + const embeddedUpdateQuery = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, { + appId, + filter: { platform, runtimeVersion: update.runtimeVersion, channel }, + first: PREWARM_EMBEDDED_UPDATES_LIMIT, + }); + for (const edge of embeddedUpdateQuery.edges) { + embeddedUpdateIdSet.add(edge.node.id); + } + } + const embeddedUpdateIds = [...embeddedUpdateIdSet].slice(0, PREWARM_EMBEDDED_UPDATES_LIMIT); + Log.debug( + `Found ${embeddedUpdateIds.length} embedded bundle(s) for update ${update.id}: ${embeddedUpdateIds.join(', ')}` + ); + + const warmupRequests: { updateId: string; embeddedUpdateId: string }[] = []; + + // pre-warm update from embedded bundle(s) to the new update + if (embeddedUpdateIds.length > 0) { + for (const embeddedUpdateId of embeddedUpdateIds) { + warmupRequests.push({ updateId: embeddedUpdateId, embeddedUpdateId }); + } + } + + // pre-warm top-K of recent updates + for (const updateId of recentUpdateIds) { + if (!embeddedUpdateIds.includes(updateId)) { + const embeddedUpdateId = + embeddedUpdateIds.length > 0 ? embeddedUpdateIds[0] : DUMMY_EMBEDDED_UPDATE_ID; + warmupRequests.push({ updateId, embeddedUpdateId }); + } + } + + Log.debug(`Pre-warming ${warmupRequests.length} patch(es) for update ${update.id}`); + const settled = await Promise.allSettled( + warmupRequests.map(async ({ updateId, embeddedUpdateId }): Promise => { + const headers: Record = { + ...(first.headers as Record | undefined), + 'expo-current-update-id': updateId, + 'expo-requested-update-id': update.id, + 'expo-embedded-update-id': embeddedUpdateId, + 'a-im': 'bsdiff', + }; + + Log.debug( + `Pre-warming patch for update ${update.id} from current update ${updateId} (embedded update ${embeddedUpdateId})` + ); + await fetch(first.url, { + method: 'HEAD', + headers, + signal: AbortSignal.timeout(2500), + }); + return { + requestedUpdateId: update.id, + currentUpdateId: updateId, + embeddedUpdateId, + }; + }) + ); + return settled + .filter( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled' + ) + .map(result => result.value); + } catch (e) { + // ignore errors, best-effort optimization + Log.debug(`Pre-warming diffing failed for update ${update.id}:`, e); + return []; + } +} + +// Make authenticated requests to the launch asset URL with diffing headers. Returns the bsdiff +// patches that were successfully pre-warmed across all of the given updates. export async function prewarmDiffingAsync( graphqlClient: ExpoGraphqlClient, appId: string, newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'] -): Promise { - const DUMMY_EMBEDDED_UPDATE_ID = '00000000-0000-0000-0000-000000000000'; - +): Promise { const toPrewarm = [] as { - update: UpdatePublishMutation['updateBranch']['publishUpdateGroups'][0]; + update: UpdatePublishMutation['updateBranch']['publishUpdateGroups'][number]; launchAssetKey: string; }[]; + Log.debug(`Considering ${newUpdates.length} update(s) for bsdiff pre-warming`); + for (const update of newUpdates) { const manifest = JSON.parse(update.manifestFragment); const launchAssetKey: string | undefined = manifest.launchAsset?.storageKey; const requestedUpdateId: string = update.id; if (!launchAssetKey || !requestedUpdateId) { + Log.debug(`Skipping update ${update.id} for pre-warming: no launch asset key`); continue; } + Log.debug( + `Queued update ${update.id} for pre-warming (platform: ${update.platform}, runtime version: ${update.runtimeVersion}, launch asset: ${launchAssetKey})` + ); toPrewarm.push({ update, launchAssetKey, }); } - await Promise.allSettled( - toPrewarm.map(async ({ update, launchAssetKey }) => { - try { - // Check to see if there's a second most recent update so we can pre-emptively generate a patch for it - const updatePublishPlatform = update.platform as UpdatePublishPlatform; - const updateIds = await BranchQuery.getUpdateIdsOnBranchAsync(graphqlClient, { - appId, - branchName: update.branch.name, - platform: updatePublishPlatformToAppPlatform[updatePublishPlatform], - runtimeVersion: update.runtimeVersion, - limit: 2, - }); - if (updateIds.length !== 2) { - return; - } - const nextMostRecentUpdateId = updateIds[1]; - - const signed = await AssetQuery.getSignedUrlsAsync(graphqlClient, update.id, [ - launchAssetKey, - ]); - const first = signed?.[0]; - if (!first) { - return; - } - - const headers: Record = { - ...(first.headers as Record | undefined), - 'expo-current-update-id': nextMostRecentUpdateId, - 'expo-requested-update-id': update.id, - 'expo-embedded-update-id': DUMMY_EMBEDDED_UPDATE_ID, - 'a-im': 'bsdiff', - }; - - await fetch(first.url, { - method: 'HEAD', - headers, - signal: AbortSignal.timeout(2500), - }); - } catch { - // ignore errors, best-effort optimization - } - }) + const warmedDiffsPerUpdate = await Promise.all( + toPrewarm.map(({ update, launchAssetKey }) => + prewarmUpdateDiffsAsync(graphqlClient, appId, update, launchAssetKey) + ) ); + return warmedDiffsPerUpdate.flat(); } // update publish does not currently support web