@bravemobile/react-native-code-push 13.1.0-beta.2 → 13.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +48 -1
  2. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +22 -8
  3. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +5 -2
  4. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +37 -0
  5. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +189 -2
  6. package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
  7. package/cli/commands/releaseCommand/index.test.ts +32 -0
  8. package/cli/commands/releaseCommand/index.ts +16 -1
  9. package/cli/commands/releaseCommand/release.test.ts +259 -10
  10. package/cli/commands/releaseCommand/release.ts +163 -8
  11. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
  12. package/cli/dist/commands/releaseCommand/index.js +12 -2
  13. package/cli/dist/commands/releaseCommand/index.test.js +25 -0
  14. package/cli/dist/commands/releaseCommand/release.js +108 -10
  15. package/cli/dist/commands/releaseCommand/release.test.js +205 -7
  16. package/cli/dist/functions/makeAssetDiffBundle.js +114 -0
  17. package/cli/dist/functions/makeAssetDiffBundle.test.js +215 -0
  18. package/cli/dist/functions/resolveAssetDiffBases.js +76 -0
  19. package/cli/dist/functions/resolveAssetDiffBases.test.js +188 -0
  20. package/cli/dist/utils/zip.js +12 -2
  21. package/cli/functions/makeAssetDiffBundle.test.ts +283 -0
  22. package/cli/functions/makeAssetDiffBundle.ts +166 -0
  23. package/cli/functions/resolveAssetDiffBases.test.ts +234 -0
  24. package/cli/functions/resolveAssetDiffBases.ts +108 -0
  25. package/cli/package.json +2 -0
  26. package/cli/utils/zip.ts +14 -3
  27. package/ios/CodePush/CodePushBinaryPatch.m +21 -8
  28. package/ios/CodePush/CodePushPackage.m +29 -2
  29. package/package.json +1 -1
  30. package/src/CodePush.js +7 -1
  31. package/src/CodePush.test.js +75 -5
  32. package/typings/react-native-code-push.d.ts +49 -0
@@ -1,9 +1,11 @@
1
+ import crypto from "crypto";
1
2
  import fs from "fs";
2
3
  import os from "os";
3
4
  import path from "path";
4
5
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
5
6
  import { release } from "./release.js";
6
7
  import { makeCodePushBundle } from "../../functions/makeCodePushBundle.js";
8
+ import { ASSET_DIFF_ARCHIVE_INFIX, assetDiffArchiveName } from "../../functions/makeAssetDiffBundle.js";
7
9
  import {
8
10
  BINARY_PATCH_ARCHIVE_SUFFIX,
9
11
  BINARY_PATCH_BASE_RECORD_NAME,
@@ -15,7 +17,7 @@ import {
15
17
  import { applyPatch } from "../../utils/binaryPatch.js";
16
18
  import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
17
19
  import { unzip } from "../../utils/unzip.js";
18
- import type { ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
20
+ import type { BundleUploadArtifact, CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
19
21
 
20
22
  /**
21
23
  * Covers the release flow around the binary patch option with `--skip-bundle`, which is
@@ -90,29 +92,38 @@ class ProcessExitError extends Error {
90
92
  }
91
93
  }
92
94
 
93
- type Uploads = { filePath: string, downloadUrl: string, logCountBeforeUpload: number }[];
94
-
95
- function recordingUploader(uploads: Uploads, failOn?: (filePath: string) => boolean) {
96
- return async (filePath: string) => {
95
+ type Uploads = {
96
+ artifact: BundleUploadArtifact | undefined;
97
+ downloadUrl: string;
98
+ filePath: string;
99
+ logCountBeforeUpload: number;
100
+ }[];
101
+
102
+ function recordingUploader(
103
+ uploads: Uploads,
104
+ failOn?: (filePath: string) => boolean,
105
+ ): CliConfigInterface['bundleUploader'] {
106
+ return async (filePath, _platform, _identifier, artifact) => {
97
107
  if (failOn?.(filePath)) {
98
108
  throw new Error(`upload rejected: ${path.basename(filePath)}`);
99
109
  }
100
110
  const downloadUrl = `https://cdn.example.com/${path.basename(filePath)}`;
101
- uploads.push({ filePath, downloadUrl, logCountBeforeUpload: logs.length });
111
+ uploads.push({ artifact, filePath, downloadUrl, logCountBeforeUpload: logs.length });
102
112
  return { downloadUrl };
103
113
  };
104
114
  }
105
115
 
106
- function historyStore() {
116
+ function historyStore(existingHistory: ReleaseHistoryInterface = {}, onSave?: () => void) {
107
117
  const saved: ReleaseHistoryInterface[] = [];
108
118
  return {
109
119
  saved,
110
- getReleaseHistory: async () => ({}) as ReleaseHistoryInterface,
120
+ getReleaseHistory: async () => existingHistory,
111
121
  setReleaseHistory: async (
112
122
  _binaryVersion: string,
113
123
  _jsonFilePath: string,
114
124
  releaseInfo: ReleaseHistoryInterface,
115
125
  ) => {
126
+ onSave?.();
116
127
  saved.push(releaseInfo);
117
128
  },
118
129
  };
@@ -127,11 +138,18 @@ type ReleaseOverrides = {
127
138
  onOversizedPatch?: OversizedPatchPolicy;
128
139
  /** Passed in when the case has to read the uploads of a release that threw. */
129
140
  uploads?: Uploads;
141
+ /** What the consumer already has released, which the asset diff bases are picked from. */
142
+ releaseHistory?: ReleaseHistoryInterface;
143
+ bundleDownloader?: CliConfigInterface['bundleDownloader'];
144
+ diffBaseCount?: number;
130
145
  };
131
146
 
132
147
  async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) {
133
148
  const uploads: Uploads = overrides.uploads ?? [];
134
- const history = historyStore();
149
+ const uploadCountsWhenHistorySaved: number[] = [];
150
+ const history = historyStore(overrides.releaseHistory, () => {
151
+ uploadCountsWhenHistorySaved.push(uploads.length);
152
+ });
135
153
 
136
154
  await release(
137
155
  recordingUploader(uploads, overrides.uploadFailsFor),
@@ -156,9 +174,11 @@ async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}
156
174
  undefined,
157
175
  overrides.binaryBundlePath,
158
176
  overrides.onOversizedPatch,
177
+ overrides.bundleDownloader,
178
+ overrides.diffBaseCount,
159
179
  );
160
180
 
161
- return { uploads, releaseHistories: history.saved };
181
+ return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
162
182
  }
163
183
 
164
184
  let logs: string[];
@@ -192,6 +212,17 @@ afterEach(() => {
192
212
  });
193
213
 
194
214
  describe("release without --binary-bundle-path", () => {
215
+ it("keeps existing three-argument uploaders callable", async () => {
216
+ const legacyUploader = async (
217
+ source: string,
218
+ _platform: 'ios' | 'android',
219
+ _identifier?: string,
220
+ ) => ({ downloadUrl: source });
221
+ const uploader: CliConfigInterface['bundleUploader'] = legacyUploader;
222
+
223
+ await expect(uploader('bundle.zip', 'ios')).resolves.toEqual({ downloadUrl: 'bundle.zip' });
224
+ });
225
+
195
226
  it("uploads the full bundle only, exactly as before binary patches existed", async () => {
196
227
  const staged = await stageBundleOutput("full-only");
197
228
 
@@ -199,6 +230,11 @@ describe("release without --binary-bundle-path", () => {
199
230
 
200
231
  expect(uploads).toHaveLength(1);
201
232
  expect(uploads[0].filePath).toBe(`${staged.bundleDirectory}/${staged.bundleFileName}`);
233
+ expect(uploads[0].artifact).toEqual({
234
+ type: 'full-bundle',
235
+ targetBinaryVersion: BINARY_VERSION,
236
+ packageHash: staged.bundleFileName,
237
+ });
202
238
  expect(fs.readdirSync(staged.bundleDirectory)).toEqual([staged.bundleFileName]);
203
239
  expect(releaseHistories[0][APP_VERSION]).toEqual({
204
240
  enabled: true,
@@ -580,3 +616,216 @@ describe("release --on-oversized-patch", () => {
580
616
  expect(uploads).toHaveLength(2);
581
617
  });
582
618
  });
619
+
620
+ /**
621
+ * A release can also ship a diff per recently released package: the patch archive without
622
+ * the files that package already holds. The bases come out of the release history and are
623
+ * downloaded through the consumer's `bundleDownloader`, so a release only builds diffs
624
+ * where the consumer supplied one.
625
+ */
626
+ describe("release with asset diff bases", () => {
627
+ /**
628
+ * The asset both packages hold. Sized like a real asset because a diff is only shipped
629
+ * when it comes out smaller than the patch archive it was derived from.
630
+ */
631
+ const sharedAsset = crypto.randomBytes(4096);
632
+
633
+ const PREVIOUS_APP_VERSION = '9.9.9';
634
+
635
+ function updateFiles(): Record<string, Buffer | string> {
636
+ return { 'main.jsbundle': fs.readFileSync(targetFixture), 'assets/keep.png': sharedAsset };
637
+ }
638
+
639
+ type StagedBaseRelease = { downloadUrl: string, packageHash: string, archivePath: string };
640
+
641
+ /**
642
+ * A package released earlier, as the history describes it and as the CDN serves it.
643
+ *
644
+ * @param ownAsset An asset only this release holds, which is what makes two staged base releases two packages rather than one.
645
+ */
646
+ async function stageBaseRelease(caseName: string, ownAsset?: string): Promise<StagedBaseRelease> {
647
+ const caseDir = fs.mkdtempSync(path.join(workDir, `${caseName}-base-`));
648
+ const contentsPath = path.join(caseDir, CONTENTS_DIR_NAME);
649
+ const files: Record<string, Buffer | string> = {
650
+ 'main.jsbundle': fs.readFileSync(baseFixture),
651
+ 'assets/keep.png': sharedAsset,
652
+ ...(ownAsset === undefined ? {} : { 'assets/dropped.png': ownAsset }),
653
+ };
654
+
655
+ for (const [relativePath, content] of Object.entries(files)) {
656
+ const filePath = path.join(contentsPath, relativePath);
657
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
658
+ fs.writeFileSync(filePath, content);
659
+ }
660
+
661
+ const archiveDir = path.join(caseDir, 'archive');
662
+ const { bundleFileName } = await makeCodePushBundle(contentsPath, archiveDir);
663
+
664
+ return {
665
+ downloadUrl: `https://cdn.example.com/released/${bundleFileName}`,
666
+ packageHash: bundleFileName,
667
+ archivePath: path.join(archiveDir, bundleFileName),
668
+ };
669
+ }
670
+
671
+ function releaseHistoryOf(base: StagedBaseRelease, versions = [PREVIOUS_APP_VERSION]): ReleaseHistoryInterface {
672
+ return Object.fromEntries(versions.map((version) => [
673
+ version,
674
+ {
675
+ enabled: true,
676
+ mandatory: false,
677
+ downloadUrl: base.downloadUrl,
678
+ packageHash: base.packageHash,
679
+ },
680
+ ]));
681
+ }
682
+
683
+ /** Serves the staged base archives, and records which releases were asked for. */
684
+ function recordingDownloader(bases: StagedBaseRelease[], downloads: string[]): CliConfigInterface['bundleDownloader'] {
685
+ return async (archive) => {
686
+ downloads.push(archive.downloadUrl);
687
+ const base = bases.find((candidate) => candidate.downloadUrl === archive.downloadUrl);
688
+ if (!base) {
689
+ throw new Error(`the downloader was called with an unexpected url: ${archive.downloadUrl}`);
690
+ }
691
+ return { downloadedFilePath: base.archivePath };
692
+ };
693
+ }
694
+
695
+ it("publishes asset diff archives against recent releases and records them in the history", async () => {
696
+ const staged = await stageBundleOutput("asset-diff", updateFiles());
697
+ const base = await stageBaseRelease("asset-diff");
698
+ const downloads: string[] = [];
699
+
700
+ const { uploads, releaseHistories, uploadCountsWhenHistorySaved } = await runRelease(staged, {
701
+ binaryBundlePath: baseFixture,
702
+ releaseHistory: releaseHistoryOf(base),
703
+ bundleDownloader: recordingDownloader([base], downloads),
704
+ });
705
+
706
+ const diffFileName = `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${base.packageHash}.zip`;
707
+ // The diff is the last artifact: it is an optimisation on top of the patch, which
708
+ // is itself an optimisation on top of the full archive.
709
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
710
+ staged.bundleFileName,
711
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
712
+ diffFileName,
713
+ ]);
714
+ expect(uploads.map(({ artifact }) => artifact)).toEqual([
715
+ {
716
+ type: 'full-bundle',
717
+ targetBinaryVersion: BINARY_VERSION,
718
+ packageHash: staged.bundleFileName,
719
+ },
720
+ {
721
+ type: 'binary-patch',
722
+ targetBinaryVersion: BINARY_VERSION,
723
+ packageHash: staged.bundleFileName,
724
+ },
725
+ {
726
+ type: 'asset-diff',
727
+ targetBinaryVersion: BINARY_VERSION,
728
+ packageHash: staged.bundleFileName,
729
+ basePackageHash: base.packageHash,
730
+ },
731
+ ]);
732
+ expect(downloads).toEqual([base.downloadUrl]);
733
+ // Every artifact is uploaded before the history points at any of them.
734
+ expect(uploadCountsWhenHistorySaved).toEqual([3]);
735
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
736
+ [base.packageHash]: uploads[2].downloadUrl,
737
+ });
738
+ expect(fs.existsSync(path.join(staged.bundleDirectory, diffFileName))).toBe(true);
739
+ });
740
+
741
+ it("publishes without diff archives when the config has no bundle downloader", async () => {
742
+ const staged = await stageBundleOutput("no-downloader", updateFiles());
743
+ const base = await stageBaseRelease("no-downloader");
744
+
745
+ const { uploads, releaseHistories } = await runRelease(staged, {
746
+ binaryBundlePath: baseFixture,
747
+ releaseHistory: releaseHistoryOf(base),
748
+ });
749
+
750
+ expect(uploads).toHaveLength(2);
751
+ // A release without diffs says nothing about them, so a client reading this history
752
+ // behaves exactly as it did before asset diffs existed.
753
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
754
+ });
755
+
756
+ it("publishes without diff archives when --diff-base-count is zero", async () => {
757
+ const staged = await stageBundleOutput("zero-bases", updateFiles());
758
+ const base = await stageBaseRelease("zero-bases");
759
+ const downloads: string[] = [];
760
+
761
+ const { uploads, releaseHistories } = await runRelease(staged, {
762
+ binaryBundlePath: baseFixture,
763
+ releaseHistory: releaseHistoryOf(base),
764
+ bundleDownloader: recordingDownloader([base], downloads),
765
+ diffBaseCount: 0,
766
+ });
767
+
768
+ expect(uploads).toHaveLength(2);
769
+ expect(downloads).toEqual([]);
770
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
771
+ });
772
+
773
+ it("builds one diff archive when two releases hold the same package", async () => {
774
+ const staged = await stageBundleOutput("repeated-base", updateFiles());
775
+ const base = await stageBaseRelease("repeated-base");
776
+ const downloads: string[] = [];
777
+
778
+ const { uploads, releaseHistories } = await runRelease(staged, {
779
+ binaryBundlePath: baseFixture,
780
+ releaseHistory: releaseHistoryOf(base, ['9.9.8', PREVIOUS_APP_VERSION]),
781
+ bundleDownloader: recordingDownloader([base], downloads),
782
+ });
783
+
784
+ expect(downloads).toHaveLength(2);
785
+ expect(uploads.filter(({ filePath }) => filePath.includes(ASSET_DIFF_ARCHIVE_INFIX))).toHaveLength(1);
786
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
787
+ [base.packageHash]: uploads[2].downloadUrl,
788
+ });
789
+ });
790
+
791
+ it("releases the diffs that uploaded when one of them is rejected", async () => {
792
+ const staged = await stageBundleOutput("diff-upload-failure", updateFiles());
793
+ // Two packages to diff against, one of whose diffs the storage backend refuses.
794
+ const rejected = await stageBaseRelease("diff-upload-failure-rejected");
795
+ const kept = await stageBaseRelease("diff-upload-failure-kept", 'an asset of the older release');
796
+ const downloads: string[] = [];
797
+
798
+ const { uploads, releaseHistories } = await runRelease(staged, {
799
+ binaryBundlePath: baseFixture,
800
+ releaseHistory: {
801
+ ...releaseHistoryOf(kept, ['9.9.8']),
802
+ ...releaseHistoryOf(rejected, [PREVIOUS_APP_VERSION]),
803
+ },
804
+ bundleDownloader: recordingDownloader([kept, rejected], downloads),
805
+ uploadFailsFor: (filePath) => filePath.includes(rejected.packageHash),
806
+ });
807
+
808
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
809
+ staged.bundleFileName,
810
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
811
+ `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${kept.packageHash}.zip`,
812
+ ]);
813
+ // The release goes out with the two artifacts it needs, and describes only the diff
814
+ // a client can actually download.
815
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
816
+ [kept.packageHash]: uploads[2].downloadUrl,
817
+ });
818
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(
819
+ new RegExp(`Skipping the asset diff archive against ${rejected.packageHash}`),
820
+ );
821
+ });
822
+
823
+ it("still picks the full bundle when diff archives sit in the output directory", async () => {
824
+ const staged = await stageBundleOutput("leftover-diff");
825
+ fs.writeFileSync(path.join(staged.bundleDirectory, assetDiffArchiveName('stale', 'stale-base')), 'stale');
826
+
827
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
828
+
829
+ expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
830
+ });
831
+ });
@@ -2,9 +2,11 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { bundleCodePush, resolveJsBundleName } from "../bundleCommand/bundleCodePush.js";
4
4
  import { addToReleaseHistory } from "./addToReleaseHistory.js";
5
- import type { CliConfigInterface } from "../../../typings/react-native-code-push.d.ts";
5
+ import type { BundleUploadArtifact, CliConfigInterface, ReleaseHistoryInterface } from "../../../typings/react-native-code-push.d.ts";
6
6
  import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
7
7
  import { unzip } from "../../utils/unzip.js";
8
+ import { ASSET_DIFF_ARCHIVE_INFIX, makeAssetDiffBundle } from "../../functions/makeAssetDiffBundle.js";
9
+ import { resolveAssetDiffBases } from "../../functions/resolveAssetDiffBases.js";
8
10
  import {
9
11
  BINARY_PATCH_ARCHIVE_SUFFIX,
10
12
  BINARY_PATCH_BASE_RECORD_NAME,
@@ -20,6 +22,9 @@ import {
20
22
  type OversizedPatchPolicy,
21
23
  } from "../../functions/makeBinaryPatchBundle.js";
22
24
 
25
+ /** How many of the most recent releases a release builds asset diff archives against. */
26
+ export const DEFAULT_DIFF_BASE_COUNT = 3;
27
+
23
28
  export async function release(
24
29
  bundleUploader: CliConfigInterface['bundleUploader'],
25
30
  getReleaseHistory: CliConfigInterface['getReleaseHistory'],
@@ -42,6 +47,8 @@ export async function release(
42
47
  hashCalc?: boolean,
43
48
  baseBundlePath?: string,
44
49
  onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY,
50
+ bundleDownloader?: CliConfigInterface['bundleDownloader'],
51
+ diffBaseCount: number = DEFAULT_DIFF_BASE_COUNT,
45
52
  ): Promise<void> {
46
53
  if (baseBundlePath) {
47
54
  // Checked before the bundler runs, so the wrong base bundle costs a second rather
@@ -77,14 +84,74 @@ export async function release(
77
84
  })
78
85
  : null;
79
86
 
87
+ // A diff is the patch archive with what a base package already holds taken out, so it
88
+ // only exists where a patch does - and only a consumer that can hand back a released
89
+ // archive can supply the bases to diff against.
90
+ const assetDiffArtifacts = binaryPatch && bundleDownloader && diffBaseCount > 0
91
+ ? await makeAssetDiffArtifacts({
92
+ patchBundleFilePath: binaryPatch.patchBundleFilePath,
93
+ releaseHistory: await getReleaseHistory(binaryVersion, platform, identifier),
94
+ diffBaseCount,
95
+ bundleDownloader,
96
+ bundleDirectory,
97
+ packageHash,
98
+ targetBinaryVersion: binaryVersion,
99
+ platform,
100
+ identifier,
101
+ })
102
+ : [];
103
+
80
104
  // Every artifact is uploaded before the release history is touched, so a failed
81
105
  // upload leaves the history describing only updates that can actually be downloaded.
82
- const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, 'bundle');
106
+ const downloadUrl = await uploadArtifact(
107
+ bundleUploader,
108
+ bundleFilePath,
109
+ platform,
110
+ identifier,
111
+ {
112
+ type: 'full-bundle',
113
+ targetBinaryVersion: binaryVersion,
114
+ packageHash,
115
+ },
116
+ 'bundle',
117
+ );
83
118
  let patchDownloadUrl: string | undefined;
84
119
  if (binaryPatch) {
85
- patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, 'binary patch bundle');
120
+ patchDownloadUrl = await uploadArtifact(
121
+ bundleUploader,
122
+ binaryPatch.patchBundleFilePath,
123
+ platform,
124
+ identifier,
125
+ {
126
+ type: 'binary-patch',
127
+ targetBinaryVersion: binaryVersion,
128
+ packageHash,
129
+ },
130
+ 'binary patch bundle',
131
+ );
86
132
  console.log(`log: Binary patch archive uploaded (download url: ${patchDownloadUrl})`);
87
133
  }
134
+ const diffPackages: Record<string, string> = {};
135
+ for (const { basePackageHash, filePath } of assetDiffArtifacts) {
136
+ // A diff is an optimisation on top of the patch archive, which is uploaded by now:
137
+ // one that cannot be uploaded is left out of the history instead of failing the
138
+ // release the other two artifacts are ready for.
139
+ try {
140
+ const diffDownloadUrl = await uploadFile(bundleUploader, filePath, platform, identifier, {
141
+ type: 'asset-diff',
142
+ targetBinaryVersion: binaryVersion,
143
+ packageHash,
144
+ basePackageHash,
145
+ });
146
+ diffPackages[basePackageHash] = diffDownloadUrl;
147
+ console.log(`log: Asset diff archive against ${basePackageHash} uploaded (download url: ${diffDownloadUrl})`);
148
+ } catch (error) {
149
+ console.warn(
150
+ `warn: Skipping the asset diff archive against ${basePackageHash}: it could not be uploaded ` +
151
+ `(${error instanceof Error ? error.message : String(error)}).`,
152
+ );
153
+ }
154
+ }
88
155
 
89
156
  await addToReleaseHistory(
90
157
  appVersion,
@@ -99,6 +166,7 @@ export async function release(
99
166
  mandatory,
100
167
  enable,
101
168
  rollout,
169
+ Object.keys(diffPackages).length > 0 ? diffPackages : undefined,
102
170
  )
103
171
 
104
172
  if (!skipCleanup) {
@@ -111,9 +179,11 @@ function cleanUpOutputs(dir: string) {
111
179
  }
112
180
 
113
181
  function readBundleFileNameFrom(bundleDirectory: string): string {
114
- // A previous release of the same bundle may have left its patch archive here, and
115
- // that archive is derived from the bundle file rather than a candidate for release.
116
- const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX));
182
+ // A previous release of the same bundle may have left its patch and diff archives
183
+ // here, and those are derived from the bundle file rather than candidates for release.
184
+ const files = fs.readdirSync(bundleDirectory).filter(
185
+ (file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX) && !file.includes(ASSET_DIFF_ARCHIVE_INFIX),
186
+ );
117
187
  if (files.length !== 1) {
118
188
  console.error('The bundlePath must contain only one file.');
119
189
  process.exit(1);
@@ -229,6 +299,79 @@ async function makeBinaryPatchArtifact({
229
299
  }
230
300
  }
231
301
 
302
+ type AssetDiffArtifact = { basePackageHash: string, filePath: string };
303
+
304
+ /**
305
+ * Builds a diff artifact per recently released package, for the clients holding one.
306
+ *
307
+ * A base that cannot be downloaded or verified is left out by the resolver, and a diff
308
+ * that comes out no smaller than the patch archive is not published. Either way the
309
+ * release goes out with its full and patch archives, serving fewer diffs.
310
+ *
311
+ * @return {Promise<AssetDiffArtifact[]>} The diff archives to upload, each naming the package it was built against.
312
+ */
313
+ async function makeAssetDiffArtifacts({
314
+ patchBundleFilePath,
315
+ releaseHistory,
316
+ diffBaseCount,
317
+ bundleDownloader,
318
+ bundleDirectory,
319
+ packageHash,
320
+ targetBinaryVersion,
321
+ platform,
322
+ identifier,
323
+ }: {
324
+ patchBundleFilePath: string;
325
+ releaseHistory: ReleaseHistoryInterface;
326
+ diffBaseCount: number;
327
+ bundleDownloader: NonNullable<CliConfigInterface['bundleDownloader']>;
328
+ bundleDirectory: string;
329
+ packageHash: string;
330
+ targetBinaryVersion: string;
331
+ platform: 'ios' | 'android';
332
+ identifier: string | undefined;
333
+ }): Promise<AssetDiffArtifact[]> {
334
+ const bases = await resolveAssetDiffBases({
335
+ releaseHistory,
336
+ diffBaseCount,
337
+ bundleDownloader,
338
+ platform,
339
+ identifier,
340
+ targetBinaryVersion,
341
+ });
342
+
343
+ const artifacts: AssetDiffArtifact[] = [];
344
+ const builtAgainst = new Set<string>();
345
+ for (const base of bases) {
346
+ // Releasing identical contents twice leaves two history entries describing one
347
+ // package, and their diff is the same archive built - and named - twice.
348
+ if (builtAgainst.has(base.basePackageHash)) {
349
+ continue;
350
+ }
351
+ builtAgainst.add(base.basePackageHash);
352
+
353
+ try {
354
+ const assetDiff = await makeAssetDiffBundle({
355
+ patchBundleFilePath,
356
+ baseBundleFilePath: base.baseBundleFilePath,
357
+ bundleDirectory,
358
+ packageHash,
359
+ basePackageHash: base.basePackageHash,
360
+ });
361
+ if (assetDiff) {
362
+ artifacts.push({ basePackageHash: base.basePackageHash, filePath: assetDiff.diffBundleFilePath });
363
+ }
364
+ } catch (error) {
365
+ console.warn(
366
+ `warn: Skipping the asset diff against ${base.basePackageHash}: it could not be built ` +
367
+ `(${error instanceof Error ? error.message : String(error)}).`,
368
+ );
369
+ }
370
+ }
371
+
372
+ return artifacts;
373
+ }
374
+
232
375
  /**
233
376
  * Cross-checks the base bundle against the record a build hook exported next to it.
234
377
  *
@@ -303,18 +446,30 @@ function warnOnBaseBundleMismatch(outputPath: string, baseBundlePath: string): v
303
446
  }
304
447
  }
305
448
 
449
+ /** Uploads an artifact the release cannot go out without, so a failure ends the program. */
306
450
  async function uploadArtifact(
307
451
  bundleUploader: CliConfigInterface['bundleUploader'],
308
452
  filePath: string,
309
453
  platform: 'ios' | 'android',
310
454
  identifier: string | undefined,
455
+ artifact: BundleUploadArtifact,
311
456
  artifactName: string,
312
457
  ): Promise<string> {
313
458
  try {
314
- const { downloadUrl } = await bundleUploader(filePath, platform, identifier);
315
- return downloadUrl
459
+ return await uploadFile(bundleUploader, filePath, platform, identifier, artifact);
316
460
  } catch (error) {
317
461
  console.error(`Failed to upload the ${artifactName} file. Exiting the program.\n`, error)
318
462
  process.exit(1)
319
463
  }
320
464
  }
465
+
466
+ async function uploadFile(
467
+ bundleUploader: CliConfigInterface['bundleUploader'],
468
+ filePath: string,
469
+ platform: 'ios' | 'android',
470
+ identifier: string | undefined,
471
+ artifact: BundleUploadArtifact,
472
+ ): Promise<string> {
473
+ const { downloadUrl } = await bundleUploader(filePath, platform, identifier, artifact);
474
+ return downloadUrl
475
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout) {
3
+ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownloadUrl, binaryPatchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, diffPackages) {
4
4
  const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
5
5
  const updateInfo = releaseHistory[appVersion];
6
6
  if (updateInfo) {
@@ -19,6 +19,11 @@ export async function addToReleaseHistory(appVersion, binaryVersion, bundleDownl
19
19
  if (binaryPatchDownloadUrl) {
20
20
  newReleaseHistory[appVersion].binaryPatchDownloadUrl = binaryPatchDownloadUrl;
21
21
  }
22
+ // Same for the asset diffs: an entry only names the bases it actually published a
23
+ // diff archive for.
24
+ if (diffPackages && Object.keys(diffPackages).length > 0) {
25
+ newReleaseHistory[appVersion].diffPackages = diffPackages;
26
+ }
22
27
  if (typeof rollout === 'number') {
23
28
  newReleaseHistory[appVersion].rollout = rollout;
24
29
  }
@@ -1,6 +1,6 @@
1
1
  import { program, Option } from "commander";
2
2
  import { findAndReadConfigFile } from "../../utils/fsUtils.js";
3
- import { release } from "./release.js";
3
+ import { DEFAULT_DIFF_BASE_COUNT, release } from "./release.js";
4
4
  import { DEFAULT_OVERSIZED_PATCH_POLICY, OVERSIZED_PATCH_POLICIES, resolveBinaryBundlePathOption, } from "../../functions/makeBinaryPatchBundle.js";
5
5
  import { OUTPUT_BUNDLE_DIR, CONFIG_FILE_NAME, ROOT_OUTPUT_DIR, ENTRY_FILE } from "../../constant.js";
6
6
  program.command('release')
@@ -26,21 +26,31 @@ program.command('release')
26
26
  .addOption(new Option('--on-oversized-patch <policy>', 'what to do when the binary patch bundle is not smaller than the full bundle: "skip" releases the full bundle only, "fail" stops the release before any upload')
27
27
  .choices(OVERSIZED_PATCH_POLICIES)
28
28
  .default(DEFAULT_OVERSIZED_PATCH_POLICY))
29
+ .option('--diff-base-count <number>', 'how many recent releases to build asset diff archives against (0 disables). Requires `bundleDownloader` in the config file.', parseDecimalInt, DEFAULT_DIFF_BASE_COUNT)
29
30
  .action(async (options) => {
30
31
  const config = findAndReadConfigFile(process.cwd(), options.config);
31
32
  if (typeof options.rollout === 'number' && (options.rollout < 0 || options.rollout > 100)) {
32
33
  console.error('Rollout percentage number must be between 0 and 100 (inclusive).');
33
34
  process.exit(1);
34
35
  }
36
+ if (!Number.isInteger(options.diffBaseCount) || options.diffBaseCount < 0) {
37
+ console.error('--diff-base-count must be a whole number of releases, 0 or greater.');
38
+ process.exit(1);
39
+ }
35
40
  if (options.hashCalc && !options.skipBundle) {
36
41
  console.error('--hash-calc option can be used only when --skip-bundle is set to true.');
37
42
  process.exit(1);
38
43
  }
39
44
  // Resolved before bundling so a wrong path fails now rather than after a build.
40
45
  const baseBundlePath = resolveBinaryBundlePathOption(options.binaryBundlePath);
41
- await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch);
46
+ await release(config.bundleUploader, config.getReleaseHistory, config.setReleaseHistory, options.binaryVersion, options.appVersion, options.framework, options.platform, options.identifier, options.outputPath, options.entryFile, options.jsBundleName, options.mandatory, options.enable, options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, options.outputMetroDir, options.hashCalc, baseBundlePath, options.onOversizedPatch, config.bundleDownloader, options.diffBaseCount);
42
47
  console.log('🚀 Release completed.');
43
48
  });
49
+ // Not `parseInt` itself: commander hands a coercion function the current value as its
50
+ // second argument, which `parseInt` reads as the radix.
51
+ function parseDecimalInt(value) {
52
+ return parseInt(value, 10);
53
+ }
44
54
  function parseBoolean(value) {
45
55
  if (value === 'true')
46
56
  return true;