@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
@@ -10,6 +10,7 @@ jest.mock("./release.js");
10
10
  jest.mock("../../utils/fsUtils.js", () => ({
11
11
  findAndReadConfigFile: () => ({
12
12
  bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
13
+ bundleDownloader: async () => ({ downloadedFilePath: '/tmp/base.zip' }),
13
14
  getReleaseHistory: async () => ({}),
14
15
  setReleaseHistory: async () => undefined,
15
16
  }),
@@ -26,6 +27,8 @@ const ARG_INDEX = {
26
27
  bundleDirectory: 16,
27
28
  baseBundlePath: 19,
28
29
  onOversizedPatch: 20,
30
+ bundleDownloader: 21,
31
+ diffBaseCount: 22,
29
32
  };
30
33
  /**
31
34
  * Parses a `release` invocation against the real command definition. Commander is asked
@@ -93,4 +96,26 @@ describe("release command options", () => {
93
96
  const { release } = await import("./release.js");
94
97
  expect(jest.mocked(release)).not.toHaveBeenCalled();
95
98
  });
99
+ it("hands the release the bundle downloader from the config and three diff bases by default", async () => {
100
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);
101
+ expect(typeof args[ARG_INDEX.bundleDownloader]).toBe('function');
102
+ expect(args[ARG_INDEX.diffBaseCount]).toBe(3);
103
+ });
104
+ it("passes the chosen asset diff base count through to the release", async () => {
105
+ const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', '5']);
106
+ expect(args[ARG_INDEX.diffBaseCount]).toBe(5);
107
+ });
108
+ it.each([
109
+ ['is negative', '-1'],
110
+ ['is not a number at all', 'many'],
111
+ ])("rejects an asset diff base count that %s", async (_caseName, value) => {
112
+ jest.spyOn(console, 'error').mockImplementation(() => { });
113
+ jest.spyOn(process, 'exit').mockImplementation(((code) => {
114
+ throw new Error(`process.exit(${code})`);
115
+ }));
116
+ await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--diff-base-count', value]))
117
+ .rejects.toThrow('process.exit(1)');
118
+ const { release } = await import("./release.js");
119
+ expect(jest.mocked(release)).not.toHaveBeenCalled();
120
+ });
96
121
  });
@@ -4,8 +4,12 @@ import { bundleCodePush, resolveJsBundleName } from "../bundleCommand/bundleCode
4
4
  import { addToReleaseHistory } from "./addToReleaseHistory.js";
5
5
  import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
6
6
  import { unzip } from "../../utils/unzip.js";
7
+ import { ASSET_DIFF_ARCHIVE_INFIX, makeAssetDiffBundle } from "../../functions/makeAssetDiffBundle.js";
8
+ import { resolveAssetDiffBases } from "../../functions/resolveAssetDiffBases.js";
7
9
  import { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, DEFAULT_OVERSIZED_PATCH_POLICY, extractCodePushBundleContents, formatBinaryPatchSummary, hashBundleFile, isPatchArchiveOversized, makeBinaryPatchBundle, readBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
8
- export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY) {
10
+ /** How many of the most recent releases a release builds asset diff archives against. */
11
+ export const DEFAULT_DIFF_BASE_COUNT = 3;
12
+ export async function release(bundleUploader, getReleaseHistory, setReleaseHistory, binaryVersion, appVersion, framework, platform, identifier, outputPath, entryFile, jsBundleName, mandatory, enable, rollout, skipBundle, skipCleanup, bundleDirectory, outputMetroDir, hashCalc, baseBundlePath, onOversizedPatch = DEFAULT_OVERSIZED_PATCH_POLICY, bundleDownloader, diffBaseCount = DEFAULT_DIFF_BASE_COUNT) {
9
13
  if (baseBundlePath) {
10
14
  // Checked before the bundler runs, so the wrong base bundle costs a second rather
11
15
  // than a full build.
@@ -36,15 +40,59 @@ export async function release(bundleUploader, getReleaseHistory, setReleaseHisto
36
40
  onOversizedPatch,
37
41
  })
38
42
  : null;
43
+ // A diff is the patch archive with what a base package already holds taken out, so it
44
+ // only exists where a patch does - and only a consumer that can hand back a released
45
+ // archive can supply the bases to diff against.
46
+ const assetDiffArtifacts = binaryPatch && bundleDownloader && diffBaseCount > 0
47
+ ? await makeAssetDiffArtifacts({
48
+ patchBundleFilePath: binaryPatch.patchBundleFilePath,
49
+ releaseHistory: await getReleaseHistory(binaryVersion, platform, identifier),
50
+ diffBaseCount,
51
+ bundleDownloader,
52
+ bundleDirectory,
53
+ packageHash,
54
+ targetBinaryVersion: binaryVersion,
55
+ platform,
56
+ identifier,
57
+ })
58
+ : [];
39
59
  // Every artifact is uploaded before the release history is touched, so a failed
40
60
  // upload leaves the history describing only updates that can actually be downloaded.
41
- const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, 'bundle');
61
+ const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, {
62
+ type: 'full-bundle',
63
+ targetBinaryVersion: binaryVersion,
64
+ packageHash,
65
+ }, 'bundle');
42
66
  let patchDownloadUrl;
43
67
  if (binaryPatch) {
44
- patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, 'binary patch bundle');
68
+ patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, {
69
+ type: 'binary-patch',
70
+ targetBinaryVersion: binaryVersion,
71
+ packageHash,
72
+ }, 'binary patch bundle');
45
73
  console.log(`log: Binary patch archive uploaded (download url: ${patchDownloadUrl})`);
46
74
  }
47
- await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout);
75
+ const diffPackages = {};
76
+ for (const { basePackageHash, filePath } of assetDiffArtifacts) {
77
+ // A diff is an optimisation on top of the patch archive, which is uploaded by now:
78
+ // one that cannot be uploaded is left out of the history instead of failing the
79
+ // release the other two artifacts are ready for.
80
+ try {
81
+ const diffDownloadUrl = await uploadFile(bundleUploader, filePath, platform, identifier, {
82
+ type: 'asset-diff',
83
+ targetBinaryVersion: binaryVersion,
84
+ packageHash,
85
+ basePackageHash,
86
+ });
87
+ diffPackages[basePackageHash] = diffDownloadUrl;
88
+ console.log(`log: Asset diff archive against ${basePackageHash} uploaded (download url: ${diffDownloadUrl})`);
89
+ }
90
+ catch (error) {
91
+ console.warn(`warn: Skipping the asset diff archive against ${basePackageHash}: it could not be uploaded ` +
92
+ `(${error instanceof Error ? error.message : String(error)}).`);
93
+ }
94
+ }
95
+ await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined);
48
96
  if (!skipCleanup) {
49
97
  cleanUpOutputs(outputPath);
50
98
  }
@@ -53,9 +101,9 @@ function cleanUpOutputs(dir) {
53
101
  fs.rmSync(dir, { recursive: true });
54
102
  }
55
103
  function readBundleFileNameFrom(bundleDirectory) {
56
- // A previous release of the same bundle may have left its patch archive here, and
57
- // that archive is derived from the bundle file rather than a candidate for release.
58
- const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX));
104
+ // A previous release of the same bundle may have left its patch and diff archives
105
+ // here, and those are derived from the bundle file rather than candidates for release.
106
+ const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX) && !file.includes(ASSET_DIFF_ARCHIVE_INFIX));
59
107
  if (files.length !== 1) {
60
108
  console.error('The bundlePath must contain only one file.');
61
109
  process.exit(1);
@@ -137,6 +185,52 @@ async function makeBinaryPatchArtifact({ baseBundlePath, contentsPath, bundleFil
137
185
  }
138
186
  }
139
187
  }
188
+ /**
189
+ * Builds a diff artifact per recently released package, for the clients holding one.
190
+ *
191
+ * A base that cannot be downloaded or verified is left out by the resolver, and a diff
192
+ * that comes out no smaller than the patch archive is not published. Either way the
193
+ * release goes out with its full and patch archives, serving fewer diffs.
194
+ *
195
+ * @return {Promise<AssetDiffArtifact[]>} The diff archives to upload, each naming the package it was built against.
196
+ */
197
+ async function makeAssetDiffArtifacts({ patchBundleFilePath, releaseHistory, diffBaseCount, bundleDownloader, bundleDirectory, packageHash, targetBinaryVersion, platform, identifier, }) {
198
+ const bases = await resolveAssetDiffBases({
199
+ releaseHistory,
200
+ diffBaseCount,
201
+ bundleDownloader,
202
+ platform,
203
+ identifier,
204
+ targetBinaryVersion,
205
+ });
206
+ const artifacts = [];
207
+ const builtAgainst = new Set();
208
+ for (const base of bases) {
209
+ // Releasing identical contents twice leaves two history entries describing one
210
+ // package, and their diff is the same archive built - and named - twice.
211
+ if (builtAgainst.has(base.basePackageHash)) {
212
+ continue;
213
+ }
214
+ builtAgainst.add(base.basePackageHash);
215
+ try {
216
+ const assetDiff = await makeAssetDiffBundle({
217
+ patchBundleFilePath,
218
+ baseBundleFilePath: base.baseBundleFilePath,
219
+ bundleDirectory,
220
+ packageHash,
221
+ basePackageHash: base.basePackageHash,
222
+ });
223
+ if (assetDiff) {
224
+ artifacts.push({ basePackageHash: base.basePackageHash, filePath: assetDiff.diffBundleFilePath });
225
+ }
226
+ }
227
+ catch (error) {
228
+ console.warn(`warn: Skipping the asset diff against ${base.basePackageHash}: it could not be built ` +
229
+ `(${error instanceof Error ? error.message : String(error)}).`);
230
+ }
231
+ }
232
+ return artifacts;
233
+ }
140
234
  /**
141
235
  * Cross-checks the base bundle against the record a build hook exported next to it.
142
236
  *
@@ -198,13 +292,17 @@ function warnOnBaseBundleMismatch(outputPath, baseBundlePath) {
198
292
  'The patch is valid but larger than an aligned one.');
199
293
  }
200
294
  }
201
- async function uploadArtifact(bundleUploader, filePath, platform, identifier, artifactName) {
295
+ /** Uploads an artifact the release cannot go out without, so a failure ends the program. */
296
+ async function uploadArtifact(bundleUploader, filePath, platform, identifier, artifact, artifactName) {
202
297
  try {
203
- const { downloadUrl } = await bundleUploader(filePath, platform, identifier);
204
- return downloadUrl;
298
+ return await uploadFile(bundleUploader, filePath, platform, identifier, artifact);
205
299
  }
206
300
  catch (error) {
207
301
  console.error(`Failed to upload the ${artifactName} file. Exiting the program.\n`, error);
208
302
  process.exit(1);
209
303
  }
210
304
  }
305
+ async function uploadFile(bundleUploader, filePath, platform, identifier, artifact) {
306
+ const { downloadUrl } = await bundleUploader(filePath, platform, identifier, artifact);
307
+ return downloadUrl;
308
+ }
@@ -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 { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, BINARY_PATCH_MANIFEST_NAME, hashBundleFile, writeBinaryPatchBaseRecord, } from "../../functions/makeBinaryPatchBundle.js";
8
10
  import { applyPatch } from "../../utils/binaryPatch.js";
9
11
  import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js";
@@ -64,33 +66,37 @@ class ProcessExitError extends Error {
64
66
  }
65
67
  }
66
68
  function recordingUploader(uploads, failOn) {
67
- return async (filePath) => {
69
+ return async (filePath, _platform, _identifier, artifact) => {
68
70
  if (failOn?.(filePath)) {
69
71
  throw new Error(`upload rejected: ${path.basename(filePath)}`);
70
72
  }
71
73
  const downloadUrl = `https://cdn.example.com/${path.basename(filePath)}`;
72
- uploads.push({ filePath, downloadUrl, logCountBeforeUpload: logs.length });
74
+ uploads.push({ artifact, filePath, downloadUrl, logCountBeforeUpload: logs.length });
73
75
  return { downloadUrl };
74
76
  };
75
77
  }
76
- function historyStore() {
78
+ function historyStore(existingHistory = {}, onSave) {
77
79
  const saved = [];
78
80
  return {
79
81
  saved,
80
- getReleaseHistory: async () => ({}),
82
+ getReleaseHistory: async () => existingHistory,
81
83
  setReleaseHistory: async (_binaryVersion, _jsonFilePath, releaseInfo) => {
84
+ onSave?.();
82
85
  saved.push(releaseInfo);
83
86
  },
84
87
  };
85
88
  }
86
89
  async function runRelease(staged, overrides = {}) {
87
90
  const uploads = overrides.uploads ?? [];
88
- const history = historyStore();
91
+ const uploadCountsWhenHistorySaved = [];
92
+ const history = historyStore(overrides.releaseHistory, () => {
93
+ uploadCountsWhenHistorySaved.push(uploads.length);
94
+ });
89
95
  await release(recordingUploader(uploads, overrides.uploadFailsFor), history.getReleaseHistory, history.setReleaseHistory, BINARY_VERSION, APP_VERSION, undefined, overrides.platform ?? 'ios', undefined, staged.outputPath, 'index.ts',
90
96
  // Left unset unless the case is about -j, the way commander leaves it.
91
97
  overrides.jsBundleName, false, true, undefined, true, // skipBundle
92
- overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch);
93
- return { uploads, releaseHistories: history.saved };
98
+ overrides.skipCleanup ?? true, staged.bundleDirectory, undefined, undefined, overrides.binaryBundlePath, overrides.onOversizedPatch, overrides.bundleDownloader, overrides.diffBaseCount);
99
+ return { uploads, releaseHistories: history.saved, uploadCountsWhenHistorySaved };
94
100
  }
95
101
  let logs;
96
102
  beforeAll(() => {
@@ -118,11 +124,21 @@ afterEach(() => {
118
124
  fs.rmSync(path.resolve(process.cwd(), `${BINARY_VERSION}.json`), { force: true });
119
125
  });
120
126
  describe("release without --binary-bundle-path", () => {
127
+ it("keeps existing three-argument uploaders callable", async () => {
128
+ const legacyUploader = async (source, _platform, _identifier) => ({ downloadUrl: source });
129
+ const uploader = legacyUploader;
130
+ await expect(uploader('bundle.zip', 'ios')).resolves.toEqual({ downloadUrl: 'bundle.zip' });
131
+ });
121
132
  it("uploads the full bundle only, exactly as before binary patches existed", async () => {
122
133
  const staged = await stageBundleOutput("full-only");
123
134
  const { uploads, releaseHistories } = await runRelease(staged);
124
135
  expect(uploads).toHaveLength(1);
125
136
  expect(uploads[0].filePath).toBe(`${staged.bundleDirectory}/${staged.bundleFileName}`);
137
+ expect(uploads[0].artifact).toEqual({
138
+ type: 'full-bundle',
139
+ targetBinaryVersion: BINARY_VERSION,
140
+ packageHash: staged.bundleFileName,
141
+ });
126
142
  expect(fs.readdirSync(staged.bundleDirectory)).toEqual([staged.bundleFileName]);
127
143
  expect(releaseHistories[0][APP_VERSION]).toEqual({
128
144
  enabled: true,
@@ -365,3 +381,185 @@ describe("release --on-oversized-patch", () => {
365
381
  expect(uploads).toHaveLength(2);
366
382
  });
367
383
  });
384
+ /**
385
+ * A release can also ship a diff per recently released package: the patch archive without
386
+ * the files that package already holds. The bases come out of the release history and are
387
+ * downloaded through the consumer's `bundleDownloader`, so a release only builds diffs
388
+ * where the consumer supplied one.
389
+ */
390
+ describe("release with asset diff bases", () => {
391
+ /**
392
+ * The asset both packages hold. Sized like a real asset because a diff is only shipped
393
+ * when it comes out smaller than the patch archive it was derived from.
394
+ */
395
+ const sharedAsset = crypto.randomBytes(4096);
396
+ const PREVIOUS_APP_VERSION = '9.9.9';
397
+ function updateFiles() {
398
+ return { 'main.jsbundle': fs.readFileSync(targetFixture), 'assets/keep.png': sharedAsset };
399
+ }
400
+ /**
401
+ * A package released earlier, as the history describes it and as the CDN serves it.
402
+ *
403
+ * @param ownAsset An asset only this release holds, which is what makes two staged base releases two packages rather than one.
404
+ */
405
+ async function stageBaseRelease(caseName, ownAsset) {
406
+ const caseDir = fs.mkdtempSync(path.join(workDir, `${caseName}-base-`));
407
+ const contentsPath = path.join(caseDir, CONTENTS_DIR_NAME);
408
+ const files = {
409
+ 'main.jsbundle': fs.readFileSync(baseFixture),
410
+ 'assets/keep.png': sharedAsset,
411
+ ...(ownAsset === undefined ? {} : { 'assets/dropped.png': ownAsset }),
412
+ };
413
+ for (const [relativePath, content] of Object.entries(files)) {
414
+ const filePath = path.join(contentsPath, relativePath);
415
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
416
+ fs.writeFileSync(filePath, content);
417
+ }
418
+ const archiveDir = path.join(caseDir, 'archive');
419
+ const { bundleFileName } = await makeCodePushBundle(contentsPath, archiveDir);
420
+ return {
421
+ downloadUrl: `https://cdn.example.com/released/${bundleFileName}`,
422
+ packageHash: bundleFileName,
423
+ archivePath: path.join(archiveDir, bundleFileName),
424
+ };
425
+ }
426
+ function releaseHistoryOf(base, versions = [PREVIOUS_APP_VERSION]) {
427
+ return Object.fromEntries(versions.map((version) => [
428
+ version,
429
+ {
430
+ enabled: true,
431
+ mandatory: false,
432
+ downloadUrl: base.downloadUrl,
433
+ packageHash: base.packageHash,
434
+ },
435
+ ]));
436
+ }
437
+ /** Serves the staged base archives, and records which releases were asked for. */
438
+ function recordingDownloader(bases, downloads) {
439
+ return async (archive) => {
440
+ downloads.push(archive.downloadUrl);
441
+ const base = bases.find((candidate) => candidate.downloadUrl === archive.downloadUrl);
442
+ if (!base) {
443
+ throw new Error(`the downloader was called with an unexpected url: ${archive.downloadUrl}`);
444
+ }
445
+ return { downloadedFilePath: base.archivePath };
446
+ };
447
+ }
448
+ it("publishes asset diff archives against recent releases and records them in the history", async () => {
449
+ const staged = await stageBundleOutput("asset-diff", updateFiles());
450
+ const base = await stageBaseRelease("asset-diff");
451
+ const downloads = [];
452
+ const { uploads, releaseHistories, uploadCountsWhenHistorySaved } = await runRelease(staged, {
453
+ binaryBundlePath: baseFixture,
454
+ releaseHistory: releaseHistoryOf(base),
455
+ bundleDownloader: recordingDownloader([base], downloads),
456
+ });
457
+ const diffFileName = `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${base.packageHash}.zip`;
458
+ // The diff is the last artifact: it is an optimisation on top of the patch, which
459
+ // is itself an optimisation on top of the full archive.
460
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
461
+ staged.bundleFileName,
462
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
463
+ diffFileName,
464
+ ]);
465
+ expect(uploads.map(({ artifact }) => artifact)).toEqual([
466
+ {
467
+ type: 'full-bundle',
468
+ targetBinaryVersion: BINARY_VERSION,
469
+ packageHash: staged.bundleFileName,
470
+ },
471
+ {
472
+ type: 'binary-patch',
473
+ targetBinaryVersion: BINARY_VERSION,
474
+ packageHash: staged.bundleFileName,
475
+ },
476
+ {
477
+ type: 'asset-diff',
478
+ targetBinaryVersion: BINARY_VERSION,
479
+ packageHash: staged.bundleFileName,
480
+ basePackageHash: base.packageHash,
481
+ },
482
+ ]);
483
+ expect(downloads).toEqual([base.downloadUrl]);
484
+ // Every artifact is uploaded before the history points at any of them.
485
+ expect(uploadCountsWhenHistorySaved).toEqual([3]);
486
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
487
+ [base.packageHash]: uploads[2].downloadUrl,
488
+ });
489
+ expect(fs.existsSync(path.join(staged.bundleDirectory, diffFileName))).toBe(true);
490
+ });
491
+ it("publishes without diff archives when the config has no bundle downloader", async () => {
492
+ const staged = await stageBundleOutput("no-downloader", updateFiles());
493
+ const base = await stageBaseRelease("no-downloader");
494
+ const { uploads, releaseHistories } = await runRelease(staged, {
495
+ binaryBundlePath: baseFixture,
496
+ releaseHistory: releaseHistoryOf(base),
497
+ });
498
+ expect(uploads).toHaveLength(2);
499
+ // A release without diffs says nothing about them, so a client reading this history
500
+ // behaves exactly as it did before asset diffs existed.
501
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
502
+ });
503
+ it("publishes without diff archives when --diff-base-count is zero", async () => {
504
+ const staged = await stageBundleOutput("zero-bases", updateFiles());
505
+ const base = await stageBaseRelease("zero-bases");
506
+ const downloads = [];
507
+ const { uploads, releaseHistories } = await runRelease(staged, {
508
+ binaryBundlePath: baseFixture,
509
+ releaseHistory: releaseHistoryOf(base),
510
+ bundleDownloader: recordingDownloader([base], downloads),
511
+ diffBaseCount: 0,
512
+ });
513
+ expect(uploads).toHaveLength(2);
514
+ expect(downloads).toEqual([]);
515
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
516
+ });
517
+ it("builds one diff archive when two releases hold the same package", async () => {
518
+ const staged = await stageBundleOutput("repeated-base", updateFiles());
519
+ const base = await stageBaseRelease("repeated-base");
520
+ const downloads = [];
521
+ const { uploads, releaseHistories } = await runRelease(staged, {
522
+ binaryBundlePath: baseFixture,
523
+ releaseHistory: releaseHistoryOf(base, ['9.9.8', PREVIOUS_APP_VERSION]),
524
+ bundleDownloader: recordingDownloader([base], downloads),
525
+ });
526
+ expect(downloads).toHaveLength(2);
527
+ expect(uploads.filter(({ filePath }) => filePath.includes(ASSET_DIFF_ARCHIVE_INFIX))).toHaveLength(1);
528
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
529
+ [base.packageHash]: uploads[2].downloadUrl,
530
+ });
531
+ });
532
+ it("releases the diffs that uploaded when one of them is rejected", async () => {
533
+ const staged = await stageBundleOutput("diff-upload-failure", updateFiles());
534
+ // Two packages to diff against, one of whose diffs the storage backend refuses.
535
+ const rejected = await stageBaseRelease("diff-upload-failure-rejected");
536
+ const kept = await stageBaseRelease("diff-upload-failure-kept", 'an asset of the older release');
537
+ const downloads = [];
538
+ const { uploads, releaseHistories } = await runRelease(staged, {
539
+ binaryBundlePath: baseFixture,
540
+ releaseHistory: {
541
+ ...releaseHistoryOf(kept, ['9.9.8']),
542
+ ...releaseHistoryOf(rejected, [PREVIOUS_APP_VERSION]),
543
+ },
544
+ bundleDownloader: recordingDownloader([kept, rejected], downloads),
545
+ uploadFailsFor: (filePath) => filePath.includes(rejected.packageHash),
546
+ });
547
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
548
+ staged.bundleFileName,
549
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
550
+ `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${kept.packageHash}.zip`,
551
+ ]);
552
+ // The release goes out with the two artifacts it needs, and describes only the diff
553
+ // a client can actually download.
554
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
555
+ [kept.packageHash]: uploads[2].downloadUrl,
556
+ });
557
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(new RegExp(`Skipping the asset diff archive against ${rejected.packageHash}`));
558
+ });
559
+ it("still picks the full bundle when diff archives sit in the output directory", async () => {
560
+ const staged = await stageBundleOutput("leftover-diff");
561
+ fs.writeFileSync(path.join(staged.bundleDirectory, assetDiffArchiveName('stale', 'stale-base')), 'stale');
562
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
563
+ expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
564
+ });
565
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Builds the diff artifact a client can install on top of one particular base package.
3
+ *
4
+ * A diff archive is the patch archive of this release with every file the base package
5
+ * already holds byte for byte taken out, plus a manifest naming the files the base holds
6
+ * that the update dropped. A client copies its installed base package, applies the
7
+ * deletions, rebuilds the JS bundle from the patch and overlays what the diff shipped -
8
+ * which leaves it holding exactly the contents of the full archive, and therefore the
9
+ * same `packageHash`. One release can serve a diff per base it was built against.
10
+ *
11
+ * The diff is derived from the artifacts the release already produced rather than from
12
+ * the bundler, so it costs no extra bundling and cannot drift from what was published.
13
+ */
14
+ import fs from "fs";
15
+ import path from "path";
16
+ import shell from "shelljs";
17
+ import { normalizePath } from "../utils/file-utils.js";
18
+ import { walk } from "../utils/promisfied-fs.js";
19
+ import { zipDirectoryContents } from "../utils/zip.js";
20
+ import { BINARY_PATCH_MANIFEST_NAME, extractCodePushBundleContents, } from "./makeBinaryPatchBundle.js";
21
+ /**
22
+ * Manifest listing what a client must delete from its copy of the base package. It sits
23
+ * at the archive root, outside the contents directory, because that is where the native
24
+ * clients look for it - and because a file inside the contents would change the package
25
+ * hash the merged folder has to reproduce.
26
+ */
27
+ export const ASSET_DIFF_MANIFEST_NAME = 'hotcodepush.json';
28
+ /** Separates the two hashes, so a diff archive names both the update and the base it needs. */
29
+ export const ASSET_DIFF_ARCHIVE_INFIX = '-diff-';
30
+ const TEMP_ASSET_DIFF_DIR_NAME = 'temp_contents_for_asset_diff';
31
+ export function assetDiffArchiveName(packageHash, basePackageHash) {
32
+ return `${packageHash}${ASSET_DIFF_ARCHIVE_INFIX}${basePackageHash}.zip`;
33
+ }
34
+ /**
35
+ * Creates the diff archive for a release against one base package.
36
+ *
37
+ * @param patchBundleFilePath {string} Patch archive this release already built
38
+ * @param baseBundleFilePath {string} Full archive of the base release, downloaded locally
39
+ * @param bundleDirectory {string} Directory the release artifacts are written to
40
+ * @param packageHash {string} Package hash of this release
41
+ * @param basePackageHash {string} Package hash of the base release
42
+ * @return The diff archive, or `null` when it is not smaller than the patch archive it
43
+ * was derived from, in which case there is nothing to gain from publishing it.
44
+ */
45
+ export async function makeAssetDiffBundle({ patchBundleFilePath, baseBundleFilePath, bundleDirectory, packageHash, basePackageHash, }) {
46
+ // Kept next to the release output rather than in the system temp directory so a crash
47
+ // leaves the leftovers where the rest of the build output is cleaned up. Named after
48
+ // the base so building the diffs of one release against several bases at once cannot
49
+ // have two of them share a working directory.
50
+ const tempRoot = path.join(path.dirname(path.resolve(bundleDirectory)), `${TEMP_ASSET_DIFF_DIR_NAME}_${basePackageHash}`);
51
+ const stagingRoot = path.join(tempRoot, 'archive');
52
+ fs.rmSync(tempRoot, { recursive: true, force: true });
53
+ try {
54
+ // Extracted into separate parents: `extractCodePushBundleContents` reuses one
55
+ // directory name, so a shared parent would have the second unpack wipe the first.
56
+ const { contentsPath: patchContents } = await extractCodePushBundleContents(patchBundleFilePath, path.join(tempRoot, 'patch-archive'));
57
+ const { contentsPath: baseContents } = await extractCodePushBundleContents(baseBundleFilePath, path.join(tempRoot, 'base-archive'));
58
+ const patchManifest = JSON.parse(fs.readFileSync(path.join(patchContents, BINARY_PATCH_MANIFEST_NAME), 'utf8'));
59
+ // Manifest paths are compared against archive-relative paths, which are POSIX.
60
+ const patchFile = normalizePath(patchManifest.patchFile);
61
+ const bundlePath = normalizePath(patchManifest.bundlePath);
62
+ // What the full archive of this release holds: the patch archive without its two
63
+ // patch-only files, plus the bundle the patch rebuilds. Deletions are worked out
64
+ // against that set, not against the subset the diff happens to ship.
65
+ const patchFiles = await listFilesRelative(patchContents);
66
+ const updateFiles = new Set(patchFiles.filter((relativePath) => relativePath !== BINARY_PATCH_MANIFEST_NAME && relativePath !== patchFile));
67
+ updateFiles.add(bundlePath);
68
+ // The archive root directory name is part of the contents layout, so the diff
69
+ // archive has to reuse the name the full archive used.
70
+ const contentsDirName = path.basename(patchContents);
71
+ const stagedContents = path.join(stagingRoot, contentsDirName);
72
+ fs.cpSync(patchContents, stagedContents, { recursive: true });
73
+ for (const relativePath of patchFiles) {
74
+ const baseFile = path.join(baseContents, relativePath);
75
+ if (fs.existsSync(baseFile) && sameBytes(path.join(patchContents, relativePath), baseFile)) {
76
+ fs.rmSync(path.join(stagedContents, relativePath));
77
+ }
78
+ }
79
+ const manifest = {
80
+ deletedFiles: (await listFilesRelative(baseContents))
81
+ .filter((relativePath) => !updateFiles.has(relativePath))
82
+ .map((relativePath) => `${contentsDirName}/${relativePath}`),
83
+ };
84
+ fs.writeFileSync(path.join(stagingRoot, ASSET_DIFF_MANIFEST_NAME), JSON.stringify(manifest, null, 2));
85
+ const diffArchiveZipPath = await zipDirectoryContents(stagingRoot);
86
+ if (fs.statSync(diffArchiveZipPath).size >= fs.statSync(patchBundleFilePath).size) {
87
+ // A diff that costs a client as much as the patch archive is never the better
88
+ // artifact of the two, and the release still publishes that patch archive.
89
+ fs.rmSync(diffArchiveZipPath);
90
+ return null;
91
+ }
92
+ const diffBundleFilePath = path.join(bundleDirectory, assetDiffArchiveName(packageHash, basePackageHash));
93
+ shell.mkdir('-p', bundleDirectory);
94
+ shell.mv(diffArchiveZipPath, diffBundleFilePath);
95
+ return { diffBundleFilePath };
96
+ }
97
+ finally {
98
+ fs.rmSync(tempRoot, { recursive: true, force: true });
99
+ }
100
+ }
101
+ /** Every file below `directoryPath`, as POSIX paths relative to it. */
102
+ async function listFilesRelative(directoryPath) {
103
+ const files = await walk(directoryPath);
104
+ return files.map((filePath) => normalizePath(path.relative(directoryPath, filePath)));
105
+ }
106
+ function sameBytes(left, right) {
107
+ // A base that holds a directory where the update holds a file has nothing the update
108
+ // can be spared from downloading, so the file ships.
109
+ const rightStats = fs.statSync(right);
110
+ if (!rightStats.isFile() || fs.statSync(left).size !== rightStats.size) {
111
+ return false;
112
+ }
113
+ return fs.readFileSync(left).equals(fs.readFileSync(right));
114
+ }