From a0c649e95dad35dde1af4c0950c4464e9527454f Mon Sep 17 00:00:00 2001 From: Jeff C Date: Tue, 16 Jun 2026 15:47:48 -0700 Subject: [PATCH 1/5] [eas-cli] Pre-warm bsdiff patches against top-K base and embedded updates Instead of only diffing against the second-most-recent update on the branch, issue HEAD requests for the top-K most recent updates and the registered embedded bundles, approximating the lazy path's organic base selection from real device traffic. --- packages/eas-cli/src/update/utils.ts | 167 +++++++++++++++++++-------- 1 file changed, 122 insertions(+), 45 deletions(-) diff --git a/packages/eas-cli/src/update/utils.ts b/packages/eas-cli/src/update/utils.ts index 7ebbc0a7b0..438bfc1af5 100644 --- a/packages/eas-cli/src/update/utils.ts +++ b/packages/eas-cli/src/update/utils.ts @@ -18,7 +18,8 @@ import { } from '../graphql/generated'; import { AssetQuery } from '../graphql/queries/AssetQuery'; import { BranchQuery } from '../graphql/queries/BranchQuery'; -import { learnMore } from '../log'; +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,26 +288,140 @@ export function isBundleDiffingEnabled(exp: ExpoConfig): boolean { return (exp.updates as any)?.enableBsdiffPatchSupport === true; } +// 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. +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. + const embeddedUpdateQuery = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, { + appId, + filter: { platform, runtimeVersion: update.runtimeVersion }, + first: PREWARM_EMBEDDED_UPDATES_LIMIT, + }); + const embeddedUpdateIds = embeddedUpdateQuery.edges.map(edge => edge.node.id); + 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}`); + await Promise.allSettled( + warmupRequests.map(async ({ updateId, embeddedUpdateId }) => { + 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), + }); + }) + ); + } catch (e) { + // ignore errors, best-effort optimization + Log.debug(`Pre-warming diffing failed for update ${update.id}:`, e); + } +} + // Make authenticated requests to the launch asset URL with diffing headers export async function prewarmDiffingAsync( graphqlClient: ExpoGraphqlClient, appId: string, newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'] ): Promise { - const DUMMY_EMBEDDED_UPDATE_ID = '00000000-0000-0000-0000-000000000000'; - 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, @@ -314,47 +429,9 @@ export async function prewarmDiffingAsync( } 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 - } - }) + toPrewarm.map(({ update, launchAssetKey }) => + prewarmUpdateDiffsAsync(graphqlClient, appId, update, launchAssetKey) + ) ); } From d5d22bd059a823c5cd4ed0d6c924ebf6d9bedcb7 Mon Sep 17 00:00:00 2001 From: Jeff C Date: Tue, 16 Jun 2026 15:55:48 -0700 Subject: [PATCH 2/5] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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)) From 706ef0305520f00fb703bd8d8b33037be227ee63 Mon Sep 17 00:00:00 2001 From: Jeff C Date: Wed, 17 Jun 2026 08:56:20 -0700 Subject: [PATCH 3/5] [eas-cli] Return warmed diffs from prewarmDiffingAsync and add tests Have prewarmDiffingAsync return the list of successfully pre-warmed bsdiff patches so the top-K base + embedded-bundle selection can be asserted on, add tests for it, and route the prewarm HEAD through the ../fetch wrapper (proxy support + standard mocking). --- .../src/update/__tests__/utils-test.ts | 62 ++++++++++++++++++- packages/eas-cli/src/update/utils.ts | 41 +++++++++--- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/packages/eas-cli/src/update/__tests__/utils-test.ts b/packages/eas-cli/src/update/__tests__/utils-test.ts index f65462f9e1..d1bef91d22 100644 --- a/packages/eas-cli/src/update/__tests__/utils-test.ts +++ b/packages/eas-cli/src/update/__tests__/utils-test.ts @@ -1,4 +1,17 @@ -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 { 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/EmbeddedUpdateQuery'); describe('update utility functions', () => { describe(truncateString, () => { @@ -27,4 +40,51 @@ 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(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' }, + ]); + }); + + 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([]); + }); + }); }); diff --git a/packages/eas-cli/src/update/utils.ts b/packages/eas-cli/src/update/utils.ts index 438bfc1af5..1f097111a1 100644 --- a/packages/eas-cli/src/update/utils.ts +++ b/packages/eas-cli/src/update/utils.ts @@ -5,6 +5,7 @@ import dateFormat from 'dateformat'; import semver from 'semver'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import fetch from '../fetch'; import { AppPlatform, PartnerActor, @@ -288,15 +289,24 @@ export function isBundleDiffingEnabled(exp: ExpoConfig): boolean { return (exp.updates as any)?.enableBsdiffPatchSupport === true; } +// 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; +} + // 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. +// 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 { +): 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. @@ -329,14 +339,14 @@ async function prewarmUpdateDiffsAsync( if (recentUpdateIds.length === 0) { Log.debug(`No recent updates to pre-warm for update ${update.id}, skipping`); - return; + 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; + return []; } // Pre-warm the patch from the bundle embedded in the native binary to the new update. @@ -372,8 +382,8 @@ async function prewarmUpdateDiffsAsync( } Log.debug(`Pre-warming ${warmupRequests.length} patch(es) for update ${update.id}`); - await Promise.allSettled( - warmupRequests.map(async ({ updateId, embeddedUpdateId }) => { + const settled = await Promise.allSettled( + warmupRequests.map(async ({ updateId, embeddedUpdateId }): Promise => { const headers: Record = { ...(first.headers as Record | undefined), 'expo-current-update-id': updateId, @@ -390,20 +400,32 @@ async function prewarmUpdateDiffsAsync( 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 +// 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 { +): Promise { const toPrewarm = [] as { update: UpdatePublishMutation['updateBranch']['publishUpdateGroups'][number]; launchAssetKey: string; @@ -428,11 +450,12 @@ export async function prewarmDiffingAsync( }); } - await Promise.allSettled( + const warmedDiffsPerUpdate = await Promise.all( toPrewarm.map(({ update, launchAssetKey }) => prewarmUpdateDiffsAsync(graphqlClient, appId, update, launchAssetKey) ) ); + return warmedDiffsPerUpdate.flat(); } // update publish does not currently support web From fcc30294c1290091a6e6943c426a1314b3c91981 Mon Sep 17 00:00:00 2001 From: Jeff C Date: Wed, 17 Jun 2026 11:29:23 -0700 Subject: [PATCH 4/5] [eas-cli] Cover prewarmDiffingAsync early-exit branches Add minimal tests for the no-recent-updates, no-signed-url, and no-launch-asset cases to reach full patch coverage. --- .../src/update/__tests__/utils-test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/eas-cli/src/update/__tests__/utils-test.ts b/packages/eas-cli/src/update/__tests__/utils-test.ts index d1bef91d22..1d5b765966 100644 --- a/packages/eas-cli/src/update/__tests__/utils-test.ts +++ b/packages/eas-cli/src/update/__tests__/utils-test.ts @@ -86,5 +86,30 @@ describe('update utility functions', () => { 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([]); + }); }); }); From dd189b30bba3559d58e919d050c01d4b3962ee9d Mon Sep 17 00:00:00 2001 From: Jeff C Date: Thu, 18 Jun 2026 14:53:37 -0700 Subject: [PATCH 5/5] Update for channel filtering per feedback --- .../src/update/__tests__/utils-test.ts | 63 +++++++++++++++++++ packages/eas-cli/src/update/utils.ts | 60 ++++++++++++++++-- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/eas-cli/src/update/__tests__/utils-test.ts b/packages/eas-cli/src/update/__tests__/utils-test.ts index 1d5b765966..4914abb971 100644 --- a/packages/eas-cli/src/update/__tests__/utils-test.ts +++ b/packages/eas-cli/src/update/__tests__/utils-test.ts @@ -5,14 +5,24 @@ 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, () => { it('does not alter messages with less than 1024 characters', () => { @@ -66,6 +76,17 @@ describe('update utility functions', () => { 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); @@ -78,6 +99,48 @@ describe('update utility functions', () => { { 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 () => { diff --git a/packages/eas-cli/src/update/utils.ts b/packages/eas-cli/src/update/utils.ts index 1f097111a1..d8fd783b24 100644 --- a/packages/eas-cli/src/update/utils.ts +++ b/packages/eas-cli/src/update/utils.ts @@ -4,6 +4,7 @@ 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 { @@ -19,6 +20,7 @@ import { } 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 Log, { learnMore } from '../log'; import { RequestedPlatform } from '../platform'; @@ -297,6 +299,32 @@ interface PrewarmedDiff { 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 @@ -353,12 +381,34 @@ async function prewarmUpdateDiffsAsync( // 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. - const embeddedUpdateQuery = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, { + // + // 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, - filter: { platform, runtimeVersion: update.runtimeVersion }, - first: PREWARM_EMBEDDED_UPDATES_LIMIT, - }); - const embeddedUpdateIds = embeddedUpdateQuery.edges.map(edge => edge.node.id); + 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(', ')}` );