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

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 +43 -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 +212 -5
  10. package/cli/commands/releaseCommand/release.ts +128 -6
  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 +90 -7
  15. package/cli/dist/commands/releaseCommand/release.test.js +175 -5
  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 +71 -0
  19. package/cli/dist/functions/resolveAssetDiffBases.test.js +174 -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 +220 -0
  24. package/cli/functions/resolveAssetDiffBases.ts +101 -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 +19 -0
@@ -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,6 +40,21 @@ 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
+ platform,
55
+ identifier,
56
+ })
57
+ : [];
39
58
  // Every artifact is uploaded before the release history is touched, so a failed
40
59
  // upload leaves the history describing only updates that can actually be downloaded.
41
60
  const downloadUrl = await uploadArtifact(bundleUploader, bundleFilePath, platform, identifier, 'bundle');
@@ -44,7 +63,22 @@ export async function release(bundleUploader, getReleaseHistory, setReleaseHisto
44
63
  patchDownloadUrl = await uploadArtifact(bundleUploader, binaryPatch.patchBundleFilePath, platform, identifier, 'binary patch bundle');
45
64
  console.log(`log: Binary patch archive uploaded (download url: ${patchDownloadUrl})`);
46
65
  }
47
- await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout);
66
+ const diffPackages = {};
67
+ for (const { basePackageHash, filePath } of assetDiffArtifacts) {
68
+ // A diff is an optimisation on top of the patch archive, which is uploaded by now:
69
+ // one that cannot be uploaded is left out of the history instead of failing the
70
+ // release the other two artifacts are ready for.
71
+ try {
72
+ const diffDownloadUrl = await uploadFile(bundleUploader, filePath, platform, identifier);
73
+ diffPackages[basePackageHash] = diffDownloadUrl;
74
+ console.log(`log: Asset diff archive against ${basePackageHash} uploaded (download url: ${diffDownloadUrl})`);
75
+ }
76
+ catch (error) {
77
+ console.warn(`warn: Skipping the asset diff archive against ${basePackageHash}: it could not be uploaded ` +
78
+ `(${error instanceof Error ? error.message : String(error)}).`);
79
+ }
80
+ }
81
+ await addToReleaseHistory(appVersion, binaryVersion, downloadUrl, patchDownloadUrl, packageHash, getReleaseHistory, setReleaseHistory, platform, identifier, mandatory, enable, rollout, Object.keys(diffPackages).length > 0 ? diffPackages : undefined);
48
82
  if (!skipCleanup) {
49
83
  cleanUpOutputs(outputPath);
50
84
  }
@@ -53,9 +87,9 @@ function cleanUpOutputs(dir) {
53
87
  fs.rmSync(dir, { recursive: true });
54
88
  }
55
89
  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));
90
+ // A previous release of the same bundle may have left its patch and diff archives
91
+ // here, and those are derived from the bundle file rather than candidates for release.
92
+ const files = fs.readdirSync(bundleDirectory).filter((file) => !file.endsWith(BINARY_PATCH_ARCHIVE_SUFFIX) && !file.includes(ASSET_DIFF_ARCHIVE_INFIX));
59
93
  if (files.length !== 1) {
60
94
  console.error('The bundlePath must contain only one file.');
61
95
  process.exit(1);
@@ -137,6 +171,51 @@ async function makeBinaryPatchArtifact({ baseBundlePath, contentsPath, bundleFil
137
171
  }
138
172
  }
139
173
  }
174
+ /**
175
+ * Builds a diff artifact per recently released package, for the clients holding one.
176
+ *
177
+ * A base that cannot be downloaded or verified is left out by the resolver, and a diff
178
+ * that comes out no smaller than the patch archive is not published. Either way the
179
+ * release goes out with its full and patch archives, serving fewer diffs.
180
+ *
181
+ * @return {Promise<AssetDiffArtifact[]>} The diff archives to upload, each naming the package it was built against.
182
+ */
183
+ async function makeAssetDiffArtifacts({ patchBundleFilePath, releaseHistory, diffBaseCount, bundleDownloader, bundleDirectory, packageHash, platform, identifier, }) {
184
+ const bases = await resolveAssetDiffBases({
185
+ releaseHistory,
186
+ diffBaseCount,
187
+ bundleDownloader,
188
+ platform,
189
+ identifier,
190
+ });
191
+ const artifacts = [];
192
+ const builtAgainst = new Set();
193
+ for (const base of bases) {
194
+ // Releasing identical contents twice leaves two history entries describing one
195
+ // package, and their diff is the same archive built - and named - twice.
196
+ if (builtAgainst.has(base.basePackageHash)) {
197
+ continue;
198
+ }
199
+ builtAgainst.add(base.basePackageHash);
200
+ try {
201
+ const assetDiff = await makeAssetDiffBundle({
202
+ patchBundleFilePath,
203
+ baseBundleFilePath: base.baseBundleFilePath,
204
+ bundleDirectory,
205
+ packageHash,
206
+ basePackageHash: base.basePackageHash,
207
+ });
208
+ if (assetDiff) {
209
+ artifacts.push({ basePackageHash: base.basePackageHash, filePath: assetDiff.diffBundleFilePath });
210
+ }
211
+ }
212
+ catch (error) {
213
+ console.warn(`warn: Skipping the asset diff against ${base.basePackageHash}: it could not be built ` +
214
+ `(${error instanceof Error ? error.message : String(error)}).`);
215
+ }
216
+ }
217
+ return artifacts;
218
+ }
140
219
  /**
141
220
  * Cross-checks the base bundle against the record a build hook exported next to it.
142
221
  *
@@ -198,13 +277,17 @@ function warnOnBaseBundleMismatch(outputPath, baseBundlePath) {
198
277
  'The patch is valid but larger than an aligned one.');
199
278
  }
200
279
  }
280
+ /** Uploads an artifact the release cannot go out without, so a failure ends the program. */
201
281
  async function uploadArtifact(bundleUploader, filePath, platform, identifier, artifactName) {
202
282
  try {
203
- const { downloadUrl } = await bundleUploader(filePath, platform, identifier);
204
- return downloadUrl;
283
+ return await uploadFile(bundleUploader, filePath, platform, identifier);
205
284
  }
206
285
  catch (error) {
207
286
  console.error(`Failed to upload the ${artifactName} file. Exiting the program.\n`, error);
208
287
  process.exit(1);
209
288
  }
210
289
  }
290
+ async function uploadFile(bundleUploader, filePath, platform, identifier) {
291
+ const { downloadUrl } = await bundleUploader(filePath, platform, identifier);
292
+ return downloadUrl;
293
+ }
@@ -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";
@@ -73,24 +75,28 @@ function recordingUploader(uploads, failOn) {
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(() => {
@@ -365,3 +371,167 @@ describe("release --on-oversized-patch", () => {
365
371
  expect(uploads).toHaveLength(2);
366
372
  });
367
373
  });
374
+ /**
375
+ * A release can also ship a diff per recently released package: the patch archive without
376
+ * the files that package already holds. The bases come out of the release history and are
377
+ * downloaded through the consumer's `bundleDownloader`, so a release only builds diffs
378
+ * where the consumer supplied one.
379
+ */
380
+ describe("release with asset diff bases", () => {
381
+ /**
382
+ * The asset both packages hold. Sized like a real asset because a diff is only shipped
383
+ * when it comes out smaller than the patch archive it was derived from.
384
+ */
385
+ const sharedAsset = crypto.randomBytes(4096);
386
+ const PREVIOUS_APP_VERSION = '9.9.9';
387
+ function updateFiles() {
388
+ return { 'main.jsbundle': fs.readFileSync(targetFixture), 'assets/keep.png': sharedAsset };
389
+ }
390
+ /**
391
+ * A package released earlier, as the history describes it and as the CDN serves it.
392
+ *
393
+ * @param ownAsset An asset only this release holds, which is what makes two staged base releases two packages rather than one.
394
+ */
395
+ async function stageBaseRelease(caseName, ownAsset) {
396
+ const caseDir = fs.mkdtempSync(path.join(workDir, `${caseName}-base-`));
397
+ const contentsPath = path.join(caseDir, CONTENTS_DIR_NAME);
398
+ const files = {
399
+ 'main.jsbundle': fs.readFileSync(baseFixture),
400
+ 'assets/keep.png': sharedAsset,
401
+ ...(ownAsset === undefined ? {} : { 'assets/dropped.png': ownAsset }),
402
+ };
403
+ for (const [relativePath, content] of Object.entries(files)) {
404
+ const filePath = path.join(contentsPath, relativePath);
405
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
406
+ fs.writeFileSync(filePath, content);
407
+ }
408
+ const archiveDir = path.join(caseDir, 'archive');
409
+ const { bundleFileName } = await makeCodePushBundle(contentsPath, archiveDir);
410
+ return {
411
+ downloadUrl: `https://cdn.example.com/released/${bundleFileName}`,
412
+ packageHash: bundleFileName,
413
+ archivePath: path.join(archiveDir, bundleFileName),
414
+ };
415
+ }
416
+ function releaseHistoryOf(base, versions = [PREVIOUS_APP_VERSION]) {
417
+ return Object.fromEntries(versions.map((version) => [
418
+ version,
419
+ {
420
+ enabled: true,
421
+ mandatory: false,
422
+ downloadUrl: base.downloadUrl,
423
+ packageHash: base.packageHash,
424
+ },
425
+ ]));
426
+ }
427
+ /** Serves the staged base archives, and records which releases were asked for. */
428
+ function recordingDownloader(bases, downloads) {
429
+ return async (downloadUrl) => {
430
+ downloads.push(downloadUrl);
431
+ const base = bases.find((candidate) => candidate.downloadUrl === downloadUrl);
432
+ if (!base) {
433
+ throw new Error(`the downloader was called with an unexpected url: ${downloadUrl}`);
434
+ }
435
+ return { downloadedFilePath: base.archivePath };
436
+ };
437
+ }
438
+ it("publishes asset diff archives against recent releases and records them in the history", async () => {
439
+ const staged = await stageBundleOutput("asset-diff", updateFiles());
440
+ const base = await stageBaseRelease("asset-diff");
441
+ const downloads = [];
442
+ const { uploads, releaseHistories, uploadCountsWhenHistorySaved } = await runRelease(staged, {
443
+ binaryBundlePath: baseFixture,
444
+ releaseHistory: releaseHistoryOf(base),
445
+ bundleDownloader: recordingDownloader([base], downloads),
446
+ });
447
+ const diffFileName = `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${base.packageHash}.zip`;
448
+ // The diff is the last artifact: it is an optimisation on top of the patch, which
449
+ // is itself an optimisation on top of the full archive.
450
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
451
+ staged.bundleFileName,
452
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
453
+ diffFileName,
454
+ ]);
455
+ expect(downloads).toEqual([base.downloadUrl]);
456
+ // Every artifact is uploaded before the history points at any of them.
457
+ expect(uploadCountsWhenHistorySaved).toEqual([3]);
458
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
459
+ [base.packageHash]: uploads[2].downloadUrl,
460
+ });
461
+ expect(fs.existsSync(path.join(staged.bundleDirectory, diffFileName))).toBe(true);
462
+ });
463
+ it("publishes without diff archives when the config has no bundle downloader", async () => {
464
+ const staged = await stageBundleOutput("no-downloader", updateFiles());
465
+ const base = await stageBaseRelease("no-downloader");
466
+ const { uploads, releaseHistories } = await runRelease(staged, {
467
+ binaryBundlePath: baseFixture,
468
+ releaseHistory: releaseHistoryOf(base),
469
+ });
470
+ expect(uploads).toHaveLength(2);
471
+ // A release without diffs says nothing about them, so a client reading this history
472
+ // behaves exactly as it did before asset diffs existed.
473
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
474
+ });
475
+ it("publishes without diff archives when --diff-base-count is zero", async () => {
476
+ const staged = await stageBundleOutput("zero-bases", updateFiles());
477
+ const base = await stageBaseRelease("zero-bases");
478
+ const downloads = [];
479
+ const { uploads, releaseHistories } = await runRelease(staged, {
480
+ binaryBundlePath: baseFixture,
481
+ releaseHistory: releaseHistoryOf(base),
482
+ bundleDownloader: recordingDownloader([base], downloads),
483
+ diffBaseCount: 0,
484
+ });
485
+ expect(uploads).toHaveLength(2);
486
+ expect(downloads).toEqual([]);
487
+ expect(releaseHistories[0][APP_VERSION]).not.toHaveProperty('diffPackages');
488
+ });
489
+ it("builds one diff archive when two releases hold the same package", async () => {
490
+ const staged = await stageBundleOutput("repeated-base", updateFiles());
491
+ const base = await stageBaseRelease("repeated-base");
492
+ const downloads = [];
493
+ const { uploads, releaseHistories } = await runRelease(staged, {
494
+ binaryBundlePath: baseFixture,
495
+ releaseHistory: releaseHistoryOf(base, ['9.9.8', PREVIOUS_APP_VERSION]),
496
+ bundleDownloader: recordingDownloader([base], downloads),
497
+ });
498
+ expect(downloads).toHaveLength(2);
499
+ expect(uploads.filter(({ filePath }) => filePath.includes(ASSET_DIFF_ARCHIVE_INFIX))).toHaveLength(1);
500
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
501
+ [base.packageHash]: uploads[2].downloadUrl,
502
+ });
503
+ });
504
+ it("releases the diffs that uploaded when one of them is rejected", async () => {
505
+ const staged = await stageBundleOutput("diff-upload-failure", updateFiles());
506
+ // Two packages to diff against, one of whose diffs the storage backend refuses.
507
+ const rejected = await stageBaseRelease("diff-upload-failure-rejected");
508
+ const kept = await stageBaseRelease("diff-upload-failure-kept", 'an asset of the older release');
509
+ const downloads = [];
510
+ const { uploads, releaseHistories } = await runRelease(staged, {
511
+ binaryBundlePath: baseFixture,
512
+ releaseHistory: {
513
+ ...releaseHistoryOf(kept, ['9.9.8']),
514
+ ...releaseHistoryOf(rejected, [PREVIOUS_APP_VERSION]),
515
+ },
516
+ bundleDownloader: recordingDownloader([kept, rejected], downloads),
517
+ uploadFailsFor: (filePath) => filePath.includes(rejected.packageHash),
518
+ });
519
+ expect(uploads.map(({ filePath }) => path.basename(filePath))).toEqual([
520
+ staged.bundleFileName,
521
+ `${staged.bundleFileName}${BINARY_PATCH_ARCHIVE_SUFFIX}`,
522
+ `${staged.bundleFileName}${ASSET_DIFF_ARCHIVE_INFIX}${kept.packageHash}.zip`,
523
+ ]);
524
+ // The release goes out with the two artifacts it needs, and describes only the diff
525
+ // a client can actually download.
526
+ expect(releaseHistories[0][APP_VERSION].diffPackages).toEqual({
527
+ [kept.packageHash]: uploads[2].downloadUrl,
528
+ });
529
+ expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(new RegExp(`Skipping the asset diff archive against ${rejected.packageHash}`));
530
+ });
531
+ it("still picks the full bundle when diff archives sit in the output directory", async () => {
532
+ const staged = await stageBundleOutput("leftover-diff");
533
+ fs.writeFileSync(path.join(staged.bundleDirectory, assetDiffArchiveName('stale', 'stale-base')), 'stale');
534
+ const { uploads } = await runRelease(staged, { binaryBundlePath: baseFixture });
535
+ expect(path.basename(uploads[0].filePath)).toBe(staged.bundleFileName);
536
+ });
537
+ });
@@ -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
+ }