@bravemobile/react-native-code-push 13.0.0 → 13.1.0-beta.0

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 (105) hide show
  1. package/CodePush.podspec +35 -3
  2. package/README.md +115 -3
  3. package/THIRD-PARTY-NOTICES +96 -0
  4. package/android/app/build.gradle +19 -0
  5. package/android/app/proguard-rules.pro +5 -0
  6. package/android/app/src/main/cpp/CMakeLists.txt +38 -0
  7. package/android/app/src/main/cpp/binarypatch_jni.c +181 -0
  8. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchAttempt.java +92 -0
  9. package/android/app/src/main/java/com/microsoft/codepush/react/BinaryPatchResult.java +64 -0
  10. package/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +1 -1
  11. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushBinaryPatch.java +293 -0
  12. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +25 -0
  13. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +12 -2
  14. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +159 -4
  15. package/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +13 -0
  16. package/android/app/src/main/java/com/microsoft/codepush/react/HDiffPatchNative.java +52 -0
  17. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushBinaryPatchTest.java +393 -0
  18. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerBinaryPatchTest.java +207 -0
  19. package/android/app/src/test/java/com/microsoft/codepush/react/CodePushUpdateManagerDownloadTest.java +449 -0
  20. package/android/codepush-export.gradle +115 -0
  21. package/cli/commands/bundleCommand/bundleCodePush.ts +39 -7
  22. package/cli/commands/bundleCommand/index.ts +9 -1
  23. package/cli/commands/releaseCommand/addToReleaseHistory.ts +7 -0
  24. package/cli/commands/releaseCommand/index.test.ts +118 -0
  25. package/cli/commands/releaseCommand/index.ts +20 -2
  26. package/cli/commands/releaseCommand/release.test.ts +582 -0
  27. package/cli/commands/releaseCommand/release.ts +232 -15
  28. package/cli/dist/commands/bundleCommand/bundleCodePush.js +26 -7
  29. package/cli/dist/commands/bundleCommand/index.js +5 -1
  30. package/cli/dist/commands/releaseCommand/addToReleaseHistory.js +6 -1
  31. package/cli/dist/commands/releaseCommand/index.js +8 -1
  32. package/cli/dist/commands/releaseCommand/index.test.js +96 -0
  33. package/cli/dist/commands/releaseCommand/release.js +165 -17
  34. package/cli/dist/commands/releaseCommand/release.test.js +367 -0
  35. package/cli/dist/functions/makeBinaryPatchBundle.js +248 -0
  36. package/cli/dist/functions/makeBinaryPatchBundle.test.js +321 -0
  37. package/cli/dist/functions/makeCodePushBundle.js +4 -2
  38. package/cli/dist/functions/runHermesEmitBinaryCommand.js +48 -1
  39. package/cli/dist/functions/runHermesEmitBinaryCommand.test.js +100 -0
  40. package/cli/dist/jest.globalSetup.js +38 -0
  41. package/cli/dist/utils/binaryPatch.js +86 -0
  42. package/cli/dist/utils/binaryPatch.test.js +190 -0
  43. package/cli/functions/makeBinaryPatchBundle.test.ts +410 -0
  44. package/cli/functions/makeBinaryPatchBundle.ts +367 -0
  45. package/cli/functions/makeCodePushBundle.ts +4 -2
  46. package/cli/functions/runHermesEmitBinaryCommand.test.ts +124 -0
  47. package/cli/functions/runHermesEmitBinaryCommand.ts +57 -1
  48. package/cli/jest.globalSetup.ts +43 -0
  49. package/cli/utils/binaryPatch.test.ts +234 -0
  50. package/cli/utils/binaryPatch.ts +103 -0
  51. package/cpp/binarypatch/binarypatch_zstd_decompressor.c +187 -0
  52. package/cpp/binarypatch/binarypatch_zstd_decompressor.h +47 -0
  53. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/checksum_plugin.h +52 -0
  54. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h +147 -0
  55. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.c +3315 -0
  56. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch.h +268 -0
  57. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_private.h +297 -0
  58. package/cpp/binarypatch/vendor/HDiffPatch/libHDiffPatch/HPatch/patch_types.h +434 -0
  59. package/cpp/binarypatch/vendor/zstd/common/allocations.h +55 -0
  60. package/cpp/binarypatch/vendor/zstd/common/bits.h +205 -0
  61. package/cpp/binarypatch/vendor/zstd/common/bitstream.h +454 -0
  62. package/cpp/binarypatch/vendor/zstd/common/compiler.h +464 -0
  63. package/cpp/binarypatch/vendor/zstd/common/cpu.h +249 -0
  64. package/cpp/binarypatch/vendor/zstd/common/debug.c +30 -0
  65. package/cpp/binarypatch/vendor/zstd/common/debug.h +107 -0
  66. package/cpp/binarypatch/vendor/zstd/common/entropy_common.c +340 -0
  67. package/cpp/binarypatch/vendor/zstd/common/error_private.c +64 -0
  68. package/cpp/binarypatch/vendor/zstd/common/error_private.h +158 -0
  69. package/cpp/binarypatch/vendor/zstd/common/fse.h +625 -0
  70. package/cpp/binarypatch/vendor/zstd/common/fse_decompress.c +315 -0
  71. package/cpp/binarypatch/vendor/zstd/common/huf.h +277 -0
  72. package/cpp/binarypatch/vendor/zstd/common/mem.h +422 -0
  73. package/cpp/binarypatch/vendor/zstd/common/pool.c +371 -0
  74. package/cpp/binarypatch/vendor/zstd/common/pool.h +81 -0
  75. package/cpp/binarypatch/vendor/zstd/common/portability_macros.h +171 -0
  76. package/cpp/binarypatch/vendor/zstd/common/threading.c +182 -0
  77. package/cpp/binarypatch/vendor/zstd/common/threading.h +142 -0
  78. package/cpp/binarypatch/vendor/zstd/common/xxhash.c +18 -0
  79. package/cpp/binarypatch/vendor/zstd/common/xxhash.h +7094 -0
  80. package/cpp/binarypatch/vendor/zstd/common/zstd_common.c +48 -0
  81. package/cpp/binarypatch/vendor/zstd/common/zstd_deps.h +123 -0
  82. package/cpp/binarypatch/vendor/zstd/common/zstd_internal.h +324 -0
  83. package/cpp/binarypatch/vendor/zstd/common/zstd_trace.h +156 -0
  84. package/cpp/binarypatch/vendor/zstd/decompress/huf_decompress.c +1944 -0
  85. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.c +244 -0
  86. package/cpp/binarypatch/vendor/zstd/decompress/zstd_ddict.h +44 -0
  87. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress.c +2410 -0
  88. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.c +2209 -0
  89. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_block.h +73 -0
  90. package/cpp/binarypatch/vendor/zstd/decompress/zstd_decompress_internal.h +240 -0
  91. package/cpp/binarypatch/vendor/zstd/zstd.h +3206 -0
  92. package/cpp/binarypatch/vendor/zstd/zstd_errors.h +107 -0
  93. package/ios/CodePush/CodePush.h +18 -1
  94. package/ios/CodePush/CodePush.mm +16 -1
  95. package/ios/CodePush/CodePushBinaryPatch.h +80 -0
  96. package/ios/CodePush/CodePushBinaryPatch.m +473 -0
  97. package/ios/CodePush/CodePushPackage.m +183 -2
  98. package/ios/CodePush/CodePushUpdateUtils.m +49 -1
  99. package/package.json +10 -3
  100. package/scripts/binary-patch/build-hdiffpatch.sh +86 -0
  101. package/scripts/export-embedded-bundle.sh +76 -0
  102. package/src/CodePush.js +46 -3
  103. package/src/CodePush.test.js +382 -0
  104. package/src/package-mixins.js +20 -2
  105. package/typings/react-native-code-push.d.ts +77 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Builds the binary patch artifact that ships alongside a full CodePush update.
3
+ *
4
+ * A patch archive is the full archive with the JS bundle swapped for a patch against
5
+ * the bundle that is already inside the app binary, plus a manifest describing how to
6
+ * rebuild it. Every other file (assets and so on) is copied over untouched, so a
7
+ * client that applies the patch and drops the two patch-only files holds byte-for-byte
8
+ * the same contents as the full archive - and therefore computes the same
9
+ * `packageHash`. That is what lets one release serve both artifacts.
10
+ *
11
+ * The archive is produced from an already assembled contents directory rather than
12
+ * from the bundler, so it is independent of Metro and Hermes: whoever prepared the
13
+ * contents (a fresh bundle run, or an existing bundle file unpacked for
14
+ * `--skip-bundle`) gets the same artifact.
15
+ */
16
+ import crypto from "crypto";
17
+ import fs from "fs";
18
+ import path from "path";
19
+ import shell from "shelljs";
20
+ import { BINARY_PATCH_ALGORITHM, BINARY_PATCH_FORMAT_VERSION, generatePatch, } from "../utils/binaryPatch.js";
21
+ import { unzip } from "../utils/unzip.js";
22
+ import { zip } from "../utils/zip.js";
23
+ /** Manifest file a client reads to decide whether it can apply the patch, and how. */
24
+ export const BINARY_PATCH_MANIFEST_NAME = 'codepush-binary-patch.json';
25
+ /** Appended to the full archive name so the two artifacts of a release stay paired. */
26
+ export const BINARY_PATCH_ARCHIVE_SUFFIX = '-patch.zip';
27
+ /**
28
+ * Record the `bundle` command leaves in the output root - outside the update contents,
29
+ * so it never reaches the archive - to say which base bundle the JS bundle was
30
+ * compiled against. A later `release` compares it with the base it was given.
31
+ *
32
+ * The build hooks (`android/codepush-export.gradle`, `scripts/export-embedded-bundle.sh`)
33
+ * write a record of the same name next to the bundle they export from a native build,
34
+ * which is what makes that bundle self-describing when it is later passed to
35
+ * `release --binary-bundle-path`.
36
+ */
37
+ export const BINARY_PATCH_BASE_RECORD_NAME = 'binary-patch-base.json';
38
+ const TEMP_PATCH_CONTENTS_DIR_NAME = 'temp_contents_for_binary_patch';
39
+ const TEMP_EXTRACTED_CONTENTS_DIR_NAME = 'temp_contents_from_bundle_file';
40
+ const HASH_ALGORITHM = 'sha256';
41
+ /** SHA-256 of a single file's bytes, which is what the manifest records. */
42
+ export function hashBundleFile(filePath) {
43
+ return crypto.createHash(HASH_ALGORITHM).update(fs.readFileSync(filePath)).digest('hex');
44
+ }
45
+ /**
46
+ * Resolves the `--binary-bundle-path` option to an absolute path, rejecting anything
47
+ * that is not an existing file. Called before the bundler runs so a typo surfaces
48
+ * before minutes of bundling rather than after.
49
+ */
50
+ export function resolveBaseBundlePath(binaryBundlePath) {
51
+ const resolved = path.resolve(binaryBundlePath);
52
+ let stats;
53
+ try {
54
+ stats = fs.statSync(resolved);
55
+ }
56
+ catch {
57
+ throw new Error(`--binary-bundle-path "${binaryBundlePath}" does not exist.`);
58
+ }
59
+ if (!stats.isFile()) {
60
+ throw new Error(`--binary-bundle-path "${binaryBundlePath}" is not a file.`);
61
+ }
62
+ return resolved;
63
+ }
64
+ /**
65
+ * Same as `resolveBaseBundlePath`, but reports the problem the way the other CLI
66
+ * options do. Returns `undefined` when the option was not passed at all.
67
+ */
68
+ export function resolveBinaryBundlePathOption(binaryBundlePath) {
69
+ if (!binaryBundlePath) {
70
+ return undefined;
71
+ }
72
+ try {
73
+ return resolveBaseBundlePath(binaryBundlePath);
74
+ }
75
+ catch (error) {
76
+ console.error(error instanceof Error ? error.message : error);
77
+ process.exit(1);
78
+ }
79
+ }
80
+ /**
81
+ * Creates the patch archive for an update that has already been assembled.
82
+ *
83
+ * @param contentsPath {string} Directory holding the update contents that went into the full archive
84
+ * @param baseBundlePath {string} JS bundle from the target binary, which the patch is computed against
85
+ * @param bundleRelativePath {string} Target bundle path inside `contentsPath`
86
+ * @param bundleDirectory {string} Directory the full archive was written to
87
+ * @param packageHash {string} Package hash of the full archive, which names both artifacts
88
+ */
89
+ export async function makeBinaryPatchBundle({ contentsPath, baseBundlePath, bundleRelativePath, bundleDirectory, packageHash, }) {
90
+ const resolvedContentsPath = path.resolve(contentsPath);
91
+ const targetBundlePath = path.join(resolvedContentsPath, bundleRelativePath);
92
+ if (!isFile(targetBundlePath)) {
93
+ throw new Error(`Target bundle "${bundleRelativePath}" was not found in the update contents ("${resolvedContentsPath}"). ` +
94
+ `Pass -j/--js-bundle-name if the bundle file has a different name.`);
95
+ }
96
+ const resolvedBaseBundlePath = resolveBaseBundlePath(baseBundlePath);
97
+ const baseBundleHash = hashBundleFile(resolvedBaseBundlePath);
98
+ const targetBundleHash = hashBundleFile(targetBundlePath);
99
+ if (baseBundleHash === targetBundleHash) {
100
+ console.warn('warn: The base bundle and the target bundle are identical, so the update changes nothing. Releasing anyway.');
101
+ }
102
+ // Kept next to the update contents rather than in the system temp directory so a
103
+ // crash leaves the leftovers where the rest of the build output is cleaned up.
104
+ const tempRoot = path.join(path.dirname(resolvedContentsPath), TEMP_PATCH_CONTENTS_DIR_NAME);
105
+ // The archive root directory name is part of the contents layout, so the patch
106
+ // archive has to reuse the name the full archive used.
107
+ const patchContentsPath = path.join(tempRoot, path.basename(resolvedContentsPath));
108
+ fs.rmSync(tempRoot, { recursive: true, force: true });
109
+ try {
110
+ fs.cpSync(resolvedContentsPath, patchContentsPath, { recursive: true });
111
+ fs.rmSync(path.join(patchContentsPath, bundleRelativePath));
112
+ const patchRelativePath = `${bundleRelativePath}.patch`;
113
+ generatePatch(resolvedBaseBundlePath, targetBundlePath, path.join(patchContentsPath, patchRelativePath));
114
+ const manifest = {
115
+ formatVersion: BINARY_PATCH_FORMAT_VERSION,
116
+ algorithm: BINARY_PATCH_ALGORITHM,
117
+ bundlePath: bundleRelativePath,
118
+ patchFile: patchRelativePath,
119
+ baseBundleHash,
120
+ targetBundleHash,
121
+ targetBundleSize: fs.statSync(targetBundlePath).size,
122
+ };
123
+ fs.writeFileSync(path.join(patchContentsPath, BINARY_PATCH_MANIFEST_NAME), JSON.stringify(manifest, null, 2));
124
+ const patchArchiveZipPath = await zip(patchContentsPath);
125
+ const patchBundleFilePath = path.join(bundleDirectory, `${packageHash}${BINARY_PATCH_ARCHIVE_SUFFIX}`);
126
+ shell.mkdir('-p', bundleDirectory);
127
+ shell.mv(patchArchiveZipPath, patchBundleFilePath);
128
+ return { patchBundleFilePath, manifest };
129
+ }
130
+ finally {
131
+ fs.rmSync(tempRoot, { recursive: true, force: true });
132
+ }
133
+ }
134
+ /**
135
+ * Unpacks an already built CodePush bundle file, so `release --skip-bundle` can patch
136
+ * exactly the contents that were packed instead of bundling them again.
137
+ *
138
+ * @return The extraction directory, which the caller owns and must remove, and the
139
+ * update contents root inside it.
140
+ */
141
+ export async function extractCodePushBundleContents(bundleFilePath, parentDirectory) {
142
+ const extractDir = path.resolve(path.join(parentDirectory, TEMP_EXTRACTED_CONTENTS_DIR_NAME));
143
+ fs.rmSync(extractDir, { recursive: true, force: true });
144
+ fs.mkdirSync(extractDir, { recursive: true });
145
+ try {
146
+ await unzip(path.resolve(bundleFilePath), extractDir);
147
+ }
148
+ catch (error) {
149
+ fs.rmSync(extractDir, { recursive: true, force: true });
150
+ throw error;
151
+ }
152
+ return { extractDir, contentsPath: resolveExtractedContentsPath(extractDir) };
153
+ }
154
+ /**
155
+ * A CodePush archive wraps its files in a single directory, so that directory - not
156
+ * the extraction directory - is the contents root.
157
+ */
158
+ function resolveExtractedContentsPath(extractDir) {
159
+ const entries = fs.readdirSync(extractDir, { withFileTypes: true });
160
+ if (entries.length === 1 && entries[0].isDirectory()) {
161
+ return path.join(extractDir, entries[0].name);
162
+ }
163
+ return extractDir;
164
+ }
165
+ /** Records which base bundle the JS bundle in `outputRootPath` was compiled against. */
166
+ export function writeBinaryPatchBaseRecord(outputRootPath, baseBundleHash) {
167
+ const recordPath = path.join(outputRootPath, BINARY_PATCH_BASE_RECORD_NAME);
168
+ const record = { baseBundleHash };
169
+ fs.mkdirSync(outputRootPath, { recursive: true });
170
+ fs.writeFileSync(recordPath, JSON.stringify(record, null, 2));
171
+ return recordPath;
172
+ }
173
+ /**
174
+ * Reads the record left by a previous `bundle` run. An unreadable record is treated as
175
+ * no record: it only feeds a warning, and must never be able to fail a release.
176
+ */
177
+ export function readBinaryPatchBaseRecord(outputRootPath) {
178
+ const recordPath = path.join(outputRootPath, BINARY_PATCH_BASE_RECORD_NAME);
179
+ try {
180
+ const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
181
+ if (typeof record.baseBundleHash !== 'string') {
182
+ return null;
183
+ }
184
+ return { baseBundleHash: record.baseBundleHash };
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ /** Accepted `--on-oversized-patch` values, in the order the help text lists them. */
191
+ export const OVERSIZED_PATCH_POLICIES = ['skip', 'fail'];
192
+ export const DEFAULT_OVERSIZED_PATCH_POLICY = 'skip';
193
+ /**
194
+ * Whether the patch archive is worth publishing next to the full archive.
195
+ *
196
+ * Equal sizes count as oversized: a patch that saves nothing still costs a client the
197
+ * download plus an apply step, so it is never the better artifact of the two.
198
+ */
199
+ export function isPatchArchiveOversized(fullArchiveSize, patchArchiveSize) {
200
+ return patchArchiveSize >= fullArchiveSize;
201
+ }
202
+ /**
203
+ * The operator-facing summary, printed before the artifacts are uploaded so the size of
204
+ * what is about to be published is on the record. When the patch is not smaller than the
205
+ * full archive and the release is going ahead without it, the summary says so rather
206
+ * than leaving a negative saving to be interpreted.
207
+ */
208
+ export function formatBinaryPatchSummary({ platform, baseBundleHash, targetBundleHash, fullArchiveSize, patchArchiveSize, patchSkipped = false, }) {
209
+ const savedBytes = fullArchiveSize - patchArchiveSize;
210
+ const savedRatio = fullArchiveSize > 0 ? savedBytes / fullArchiveSize : 0;
211
+ const sizes = [fullArchiveSize, patchArchiveSize, savedBytes].map(formatBytes);
212
+ const sizeWidth = Math.max(...sizes.map((size) => size.length));
213
+ const [fullSize, patchSize, savedSize] = sizes.map((size) => size.padStart(sizeWidth));
214
+ const label = (text) => text.padEnd(23);
215
+ const lines = [
216
+ `Binary patch summary (${platform})`,
217
+ `${label('Base bundle SHA-256:')}${baseBundleHash}`,
218
+ `${label('Target bundle SHA-256:')}${targetBundleHash}`,
219
+ `${label('Full archive:')}${fullSize}`,
220
+ `${label('Patch archive:')}${patchSize}`,
221
+ `${label('Saved:')}${savedSize} (${(savedRatio * 100).toFixed(1)}%)`,
222
+ ];
223
+ if (patchSkipped) {
224
+ lines.push(`${label('Patch skipped:')}not smaller than the full archive; releasing the full bundle only (--on-oversized-patch skip)`);
225
+ }
226
+ return lines.join('\n');
227
+ }
228
+ function formatBytes(bytes) {
229
+ const units = ['KB', 'MB', 'GB', 'TB'];
230
+ if (Math.abs(bytes) < 1024) {
231
+ return `${bytes} B`;
232
+ }
233
+ let value = bytes / 1024;
234
+ let unitIndex = 0;
235
+ while (Math.abs(value) >= 1024 && unitIndex < units.length - 1) {
236
+ value /= 1024;
237
+ unitIndex++;
238
+ }
239
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
240
+ }
241
+ function isFile(filePath) {
242
+ try {
243
+ return fs.statSync(filePath).isFile();
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ }
@@ -0,0 +1,321 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals";
6
+ import { BINARY_PATCH_ARCHIVE_SUFFIX, BINARY_PATCH_BASE_RECORD_NAME, BINARY_PATCH_MANIFEST_NAME, extractCodePushBundleContents, formatBinaryPatchSummary, isPatchArchiveOversized, makeBinaryPatchBundle, readBinaryPatchBaseRecord, resolveBaseBundlePath, writeBinaryPatchBaseRecord, } from "./makeBinaryPatchBundle.js";
7
+ import { makeCodePushBundle } from "./makeCodePushBundle.js";
8
+ import { applyPatch, BINARY_PATCH_ALGORITHM, BINARY_PATCH_FORMAT_VERSION } from "../utils/binaryPatch.js";
9
+ import { generatePackageHashFromDirectory } from "../utils/hash-utils.js";
10
+ import { unzip } from "../utils/unzip.js";
11
+ /**
12
+ * Exercises the patch artifact against real archives, real hdiffz output and real
13
+ * hashes: the artifact only has value if a client can rebuild the exact contents of
14
+ * the full archive from it, and only real bytes prove that.
15
+ */
16
+ /** The directory a CodePush archive keeps its contents in, for legacy reasons. */
17
+ const CONTENTS_DIR_NAME = 'CodePush';
18
+ function findRepoRoot() {
19
+ let dir = process.cwd();
20
+ for (;;) {
21
+ if (fs.existsSync(path.join(dir, "scripts", "binary-patch", "build-hdiffpatch.sh"))) {
22
+ return dir;
23
+ }
24
+ const parent = path.dirname(dir);
25
+ if (parent === dir) {
26
+ throw new Error(`cannot locate the repository root from ${process.cwd()}`);
27
+ }
28
+ dir = parent;
29
+ }
30
+ }
31
+ const repoRoot = findRepoRoot();
32
+ const fixtureDir = path.join(repoRoot, "cli", "fixtures", "binary-patch");
33
+ const baseFixture = path.join(fixtureDir, "base.bundle");
34
+ const targetFixture = path.join(fixtureDir, "target.bundle");
35
+ let workDir;
36
+ function sha256(filePath) {
37
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
38
+ }
39
+ /** A unique directory per test so a failed run never leaks state into the next one. */
40
+ function makeCaseDir(name) {
41
+ const caseDir = fs.mkdtempSync(path.join(workDir, `${name}-`));
42
+ return caseDir;
43
+ }
44
+ /**
45
+ * Writes an update contents directory shaped like the one the bundler produces: the
46
+ * JS bundle next to the assets that ship with it.
47
+ */
48
+ function writeUpdateContents(caseDir, files) {
49
+ const contentsPath = path.join(caseDir, CONTENTS_DIR_NAME);
50
+ for (const [relativePath, content] of Object.entries(files)) {
51
+ const filePath = path.join(contentsPath, relativePath);
52
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
+ fs.writeFileSync(filePath, content);
54
+ }
55
+ return contentsPath;
56
+ }
57
+ function defaultContents(caseDir, bundleName = 'main.jsbundle') {
58
+ return writeUpdateContents(caseDir, {
59
+ [bundleName]: fs.readFileSync(targetFixture),
60
+ 'assets/logo.png': Buffer.from('logo-bytes'),
61
+ });
62
+ }
63
+ async function unzipTo(archivePath, destination) {
64
+ fs.mkdirSync(destination, { recursive: true });
65
+ await unzip(archivePath, destination);
66
+ return path.join(destination, CONTENTS_DIR_NAME);
67
+ }
68
+ beforeAll(() => {
69
+ // hdiffz/hpatchz are provisioned for the whole run by the jest global setup.
70
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-patch-bundle-"));
71
+ });
72
+ afterAll(() => {
73
+ fs.rmSync(workDir, { recursive: true, force: true });
74
+ });
75
+ afterEach(() => {
76
+ jest.restoreAllMocks();
77
+ });
78
+ describe("resolveBaseBundlePath", () => {
79
+ it("returns an absolute path for an existing file", () => {
80
+ expect(resolveBaseBundlePath(path.relative(process.cwd(), baseFixture))).toBe(baseFixture);
81
+ });
82
+ it("fails with a message naming the option when the file does not exist", () => {
83
+ const missing = path.join(makeCaseDir("missing-base"), "no-such.bundle");
84
+ expect(() => resolveBaseBundlePath(missing)).toThrow(/--binary-bundle-path/);
85
+ expect(() => resolveBaseBundlePath(missing)).toThrow(/does not exist/);
86
+ });
87
+ it("fails when the path is a directory", () => {
88
+ expect(() => resolveBaseBundlePath(fixtureDir)).toThrow(/is not a file/);
89
+ });
90
+ });
91
+ describe("makeBinaryPatchBundle", () => {
92
+ it("writes a patch archive next to the full archive, named after the package hash", async () => {
93
+ const caseDir = makeCaseDir("names");
94
+ const contentsPath = defaultContents(caseDir);
95
+ const bundleDirectory = path.join(caseDir, "bundleOutput");
96
+ const { bundleFileName: packageHash } = await makeCodePushBundle(contentsPath, bundleDirectory);
97
+ const { patchBundleFilePath } = await makeBinaryPatchBundle({
98
+ contentsPath,
99
+ baseBundlePath: baseFixture,
100
+ bundleRelativePath: 'main.jsbundle',
101
+ bundleDirectory,
102
+ packageHash,
103
+ });
104
+ expect(path.basename(patchBundleFilePath)).toBe(`${packageHash}${BINARY_PATCH_ARCHIVE_SUFFIX}`);
105
+ expect(fs.existsSync(patchBundleFilePath)).toBe(true);
106
+ // The full archive stays exactly as it was uploaded before patches existed.
107
+ expect(fs.readdirSync(bundleDirectory).sort()).toEqual([packageHash, `${packageHash}${BINARY_PATCH_ARCHIVE_SUFFIX}`].sort());
108
+ });
109
+ it("pins the manifest to the codec contract and to the target bundle it describes", async () => {
110
+ const caseDir = makeCaseDir("manifest");
111
+ const contentsPath = defaultContents(caseDir);
112
+ const bundleDirectory = path.join(caseDir, "bundleOutput");
113
+ const { patchBundleFilePath, manifest } = await makeBinaryPatchBundle({
114
+ contentsPath,
115
+ baseBundlePath: baseFixture,
116
+ bundleRelativePath: 'main.jsbundle',
117
+ bundleDirectory,
118
+ packageHash: 'package-hash',
119
+ });
120
+ expect(manifest).toEqual({
121
+ formatVersion: BINARY_PATCH_FORMAT_VERSION,
122
+ algorithm: BINARY_PATCH_ALGORITHM,
123
+ bundlePath: 'main.jsbundle',
124
+ patchFile: 'main.jsbundle.patch',
125
+ baseBundleHash: sha256(baseFixture),
126
+ targetBundleHash: sha256(targetFixture),
127
+ targetBundleSize: fs.statSync(targetFixture).size,
128
+ });
129
+ const extracted = await unzipTo(patchBundleFilePath, path.join(caseDir, "extracted"));
130
+ expect(JSON.parse(fs.readFileSync(path.join(extracted, BINARY_PATCH_MANIFEST_NAME), 'utf8'))).toEqual(manifest);
131
+ });
132
+ it("replaces the target bundle with its patch and keeps every other file", async () => {
133
+ const caseDir = makeCaseDir("layout");
134
+ const contentsPath = defaultContents(caseDir);
135
+ const bundleDirectory = path.join(caseDir, "bundleOutput");
136
+ const { patchBundleFilePath } = await makeBinaryPatchBundle({
137
+ contentsPath,
138
+ baseBundlePath: baseFixture,
139
+ bundleRelativePath: 'main.jsbundle',
140
+ bundleDirectory,
141
+ packageHash: 'package-hash',
142
+ });
143
+ const extracted = await unzipTo(patchBundleFilePath, path.join(caseDir, "extracted"));
144
+ expect(fs.existsSync(path.join(extracted, 'main.jsbundle'))).toBe(false);
145
+ expect(fs.existsSync(path.join(extracted, 'main.jsbundle.patch'))).toBe(true);
146
+ expect(fs.readFileSync(path.join(extracted, 'assets/logo.png'), 'utf8')).toBe('logo-bytes');
147
+ });
148
+ it("restores the exact package hash of the full archive from the patch archive", async () => {
149
+ const caseDir = makeCaseDir("round-trip");
150
+ const contentsPath = defaultContents(caseDir);
151
+ const bundleDirectory = path.join(caseDir, "bundleOutput");
152
+ const { bundleFileName: packageHash } = await makeCodePushBundle(contentsPath, bundleDirectory);
153
+ const { patchBundleFilePath, manifest } = await makeBinaryPatchBundle({
154
+ contentsPath,
155
+ baseBundlePath: baseFixture,
156
+ bundleRelativePath: 'main.jsbundle',
157
+ bundleDirectory,
158
+ packageHash,
159
+ });
160
+ // Replay what a client does: apply the patch onto the bundle from the binary,
161
+ // then drop the patch artifacts that are not part of the update contents.
162
+ const extractRoot = path.join(caseDir, "extracted");
163
+ const extracted = await unzipTo(patchBundleFilePath, extractRoot);
164
+ applyPatch(baseFixture, path.join(extracted, manifest.patchFile), path.join(extracted, manifest.bundlePath));
165
+ fs.rmSync(path.join(extracted, manifest.patchFile));
166
+ fs.rmSync(path.join(extracted, BINARY_PATCH_MANIFEST_NAME));
167
+ expect(sha256(path.join(extracted, manifest.bundlePath))).toBe(sha256(targetFixture));
168
+ expect(await generatePackageHashFromDirectory(extracted, extractRoot)).toBe(packageHash);
169
+ });
170
+ it("patches only the bundle named by the caller, leaving another platform's bundle untouched", async () => {
171
+ const caseDir = makeCaseDir("android");
172
+ const contentsPath = writeUpdateContents(caseDir, {
173
+ 'index.android.bundle': fs.readFileSync(targetFixture),
174
+ 'main.jsbundle': Buffer.from('ios-bundle-bytes'),
175
+ });
176
+ const bundleDirectory = path.join(caseDir, "bundleOutput");
177
+ const { patchBundleFilePath, manifest } = await makeBinaryPatchBundle({
178
+ contentsPath,
179
+ baseBundlePath: baseFixture,
180
+ bundleRelativePath: 'index.android.bundle',
181
+ bundleDirectory,
182
+ packageHash: 'package-hash',
183
+ });
184
+ expect(manifest.bundlePath).toBe('index.android.bundle');
185
+ expect(manifest.patchFile).toBe('index.android.bundle.patch');
186
+ const extracted = await unzipTo(patchBundleFilePath, path.join(caseDir, "extracted"));
187
+ expect(fs.existsSync(path.join(extracted, 'index.android.bundle'))).toBe(false);
188
+ expect(fs.readFileSync(path.join(extracted, 'main.jsbundle'), 'utf8')).toBe('ios-bundle-bytes');
189
+ });
190
+ it("warns when the base and the target bundle are the same bytes", async () => {
191
+ const caseDir = makeCaseDir("identical");
192
+ const contentsPath = writeUpdateContents(caseDir, { 'main.jsbundle': fs.readFileSync(baseFixture) });
193
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
194
+ await makeBinaryPatchBundle({
195
+ contentsPath,
196
+ baseBundlePath: baseFixture,
197
+ bundleRelativePath: 'main.jsbundle',
198
+ bundleDirectory: path.join(caseDir, "bundleOutput"),
199
+ packageHash: 'package-hash',
200
+ });
201
+ expect(warn.mock.calls.join('\n')).toMatch(/identical/);
202
+ });
203
+ it("fails with an actionable error when the target bundle is not in the contents", async () => {
204
+ const caseDir = makeCaseDir("no-target");
205
+ const contentsPath = writeUpdateContents(caseDir, { 'index.android.bundle': Buffer.from('android') });
206
+ await expect(makeBinaryPatchBundle({
207
+ contentsPath,
208
+ baseBundlePath: baseFixture,
209
+ bundleRelativePath: 'main.jsbundle',
210
+ bundleDirectory: path.join(caseDir, "bundleOutput"),
211
+ packageHash: 'package-hash',
212
+ })).rejects.toThrow(/main\.jsbundle.*--js-bundle-name/s);
213
+ });
214
+ it("leaves no working directory behind, whether it succeeds or fails", async () => {
215
+ const caseDir = makeCaseDir("cleanup");
216
+ const contentsPath = defaultContents(caseDir);
217
+ const bundleDirectory = path.join(caseDir, "bundleOutput");
218
+ const entriesBefore = fs.readdirSync(caseDir).sort();
219
+ await makeBinaryPatchBundle({
220
+ contentsPath,
221
+ baseBundlePath: baseFixture,
222
+ bundleRelativePath: 'main.jsbundle',
223
+ bundleDirectory,
224
+ packageHash: 'package-hash',
225
+ });
226
+ expect(fs.readdirSync(caseDir).sort()).toEqual([...entriesBefore, 'bundleOutput'].sort());
227
+ await expect(makeBinaryPatchBundle({
228
+ contentsPath,
229
+ baseBundlePath: path.join(caseDir, 'no-such-base.bundle'),
230
+ bundleRelativePath: 'main.jsbundle',
231
+ bundleDirectory,
232
+ packageHash: 'package-hash',
233
+ })).rejects.toThrow();
234
+ expect(fs.readdirSync(caseDir).sort()).toEqual([...entriesBefore, 'bundleOutput'].sort());
235
+ });
236
+ });
237
+ describe("binary patch base record", () => {
238
+ it("is written outside the update contents, so it cannot change the package hash", async () => {
239
+ const caseDir = makeCaseDir("record");
240
+ const contentsPath = defaultContents(caseDir);
241
+ const hashBefore = await generatePackageHashFromDirectory(contentsPath, caseDir);
242
+ const recordPath = writeBinaryPatchBaseRecord(caseDir, sha256(baseFixture));
243
+ expect(path.relative(contentsPath, recordPath).startsWith('..')).toBe(true);
244
+ expect(path.basename(recordPath)).toBe(BINARY_PATCH_BASE_RECORD_NAME);
245
+ expect(await generatePackageHashFromDirectory(contentsPath, caseDir)).toBe(hashBefore);
246
+ expect(readBinaryPatchBaseRecord(caseDir)).toEqual({ baseBundleHash: sha256(baseFixture) });
247
+ });
248
+ it("reads as absent when there is no record or the record is unreadable", () => {
249
+ const caseDir = makeCaseDir("record-missing");
250
+ expect(readBinaryPatchBaseRecord(caseDir)).toBeNull();
251
+ fs.writeFileSync(path.join(caseDir, BINARY_PATCH_BASE_RECORD_NAME), 'not json');
252
+ expect(readBinaryPatchBaseRecord(caseDir)).toBeNull();
253
+ });
254
+ });
255
+ describe("extractCodePushBundleContents", () => {
256
+ it("unpacks the contents of an already built bundle file so a patch can be built from it", async () => {
257
+ const caseDir = makeCaseDir("extract");
258
+ const contentsPath = defaultContents(caseDir);
259
+ const bundleDirectory = path.join(caseDir, "bundleOutput");
260
+ const { bundleFileName } = await makeCodePushBundle(contentsPath, bundleDirectory);
261
+ const { extractDir, contentsPath: extractedContents } = await extractCodePushBundleContents(path.join(bundleDirectory, bundleFileName), caseDir);
262
+ expect(await generatePackageHashFromDirectory(extractedContents, path.dirname(extractedContents))).toBe(bundleFileName);
263
+ expect(fs.existsSync(path.join(extractedContents, 'main.jsbundle'))).toBe(true);
264
+ fs.rmSync(extractDir, { recursive: true, force: true });
265
+ });
266
+ });
267
+ describe("isPatchArchiveOversized", () => {
268
+ it("keeps a patch that is smaller than the full archive", () => {
269
+ expect(isPatchArchiveOversized(100, 99)).toBe(false);
270
+ });
271
+ it("rejects a patch of exactly the same size, which saves a client nothing", () => {
272
+ expect(isPatchArchiveOversized(100, 100)).toBe(true);
273
+ });
274
+ it("rejects a patch that is larger than the full archive", () => {
275
+ expect(isPatchArchiveOversized(100, 101)).toBe(true);
276
+ });
277
+ });
278
+ describe("formatBinaryPatchSummary", () => {
279
+ it("reports both archive sizes and what the patch saves", () => {
280
+ const baseBundleHash = 'a'.repeat(64);
281
+ const targetBundleHash = 'b'.repeat(64);
282
+ const summary = formatBinaryPatchSummary({
283
+ platform: 'ios',
284
+ baseBundleHash,
285
+ targetBundleHash,
286
+ fullArchiveSize: 19293798,
287
+ patchArchiveSize: 3250586,
288
+ });
289
+ expect(summary).toBe([
290
+ 'Binary patch summary (ios)',
291
+ `Base bundle SHA-256: ${baseBundleHash}`,
292
+ `Target bundle SHA-256: ${targetBundleHash}`,
293
+ 'Full archive: 18.4 MB',
294
+ 'Patch archive: 3.1 MB',
295
+ 'Saved: 15.3 MB (83.2%)',
296
+ ].join('\n'));
297
+ });
298
+ it("reports a negative saving when the patch is larger than the full archive", () => {
299
+ const summary = formatBinaryPatchSummary({
300
+ platform: 'android',
301
+ baseBundleHash: 'a'.repeat(64),
302
+ targetBundleHash: 'b'.repeat(64),
303
+ fullArchiveSize: 1000,
304
+ patchArchiveSize: 1500,
305
+ });
306
+ expect(summary).toContain('Binary patch summary (android)');
307
+ expect(summary).toContain('Saved: -500 B (-50.0%)');
308
+ expect(summary).not.toContain('Patch skipped:');
309
+ });
310
+ it("states that the patch was skipped, and why, when it is not being released", () => {
311
+ const summary = formatBinaryPatchSummary({
312
+ platform: 'android',
313
+ baseBundleHash: 'a'.repeat(64),
314
+ targetBundleHash: 'b'.repeat(64),
315
+ fullArchiveSize: 1000,
316
+ patchArchiveSize: 1500,
317
+ patchSkipped: true,
318
+ });
319
+ expect(summary.split('\n').at(-1)).toBe('Patch skipped: not smaller than the full archive; releasing the full bundle only (--on-oversized-patch skip)');
320
+ });
321
+ });
@@ -12,8 +12,10 @@ import { generatePackageHashFromDirectory } from "../utils/hash-utils.js";
12
12
  export async function makeCodePushBundle(contentsPath, bundleDirectory) {
13
13
  const updateContentsZipPath = await zip(contentsPath);
14
14
  const packageHash = await generatePackageHashFromDirectory(contentsPath, path.join(contentsPath, '..'));
15
- shell.mkdir('-p', `./${bundleDirectory}`);
16
- shell.mv(updateContentsZipPath, `./${bundleDirectory}/${packageHash}`);
15
+ // Joined rather than interpolated so an absolute bundle directory - which an
16
+ // absolute --output-path produces - is not turned into a path below the cwd.
17
+ shell.mkdir('-p', bundleDirectory);
18
+ shell.mv(updateContentsZipPath, path.join(bundleDirectory, packageHash));
17
19
  return {
18
20
  // To allow the "release" command to get the file and hash value from the result of the "bundle" command,
19
21
  // use the hash value as the name of the file.
@@ -6,6 +6,11 @@ import fs from "fs";
6
6
  import path from "path";
7
7
  import { createRequire } from "node:module";
8
8
  import shell from "shelljs";
9
+ /**
10
+ * Tells Hermes to lay the compiled bytecode out like an existing bundle's, which keeps
11
+ * the binary patch between the two small. Older compilers do not have the flag.
12
+ */
13
+ const BASE_BYTECODE_FLAG = '-base-bytecode';
9
14
  /**
10
15
  * Run Hermes compile CLI command
11
16
  *
@@ -32,7 +37,16 @@ export async function runHermesEmitBinaryCommand(bundleName, outputPath, sourcem
32
37
  try {
33
38
  const hermesCommand = getHermesCommand(projectRoot);
34
39
  const disableAllWarningsArg = '-w';
35
- shell.exec(`${hermesCommand} ${hermesArgs.join(' ')} ${disableAllWarningsArg}`);
40
+ const compileResult = shell.exec(`${hermesCommand} ${hermesArgs.join(' ')} ${disableAllWarningsArg}`);
41
+ if (compileResult.code !== 0) {
42
+ // Reported here rather than left to the missing .hbc file, so a rejected
43
+ // flag - a base bundle that is not Hermes bytecode, for instance - fails
44
+ // the release instead of silently producing an unaligned bundle.
45
+ const extraFlagsHint = extraHermesFlags.length > 0
46
+ ? ` Additional options: ${extraHermesFlags.join(' ')}`
47
+ : '';
48
+ throw new Error(`"hermesc" command failed (exitCode=${compileResult.code}).${extraFlagsHint}`);
49
+ }
36
50
  // Copy HBC bundle to overwrite JS bundle
37
51
  const source = path.join(outputPath, bundleName + '.hbc');
38
52
  const destination = path.join(outputPath, bundleName);
@@ -89,6 +103,39 @@ export async function runHermesEmitBinaryCommand(bundleName, outputPath, sourcem
89
103
  });
90
104
  });
91
105
  }
106
+ /**
107
+ * Builds the flags that align a compilation with the bundle already inside the app
108
+ * binary, so the binary patch between them stays small.
109
+ *
110
+ * When the app's Hermes compiler predates the flag the release still goes ahead
111
+ * without it: the patch is then computed against unaligned bytecode and is larger, but
112
+ * it is still a valid patch. A compiler that does accept the flag and fails is a
113
+ * different matter and fails the release, because the base input is then wrong.
114
+ *
115
+ * @param baseBundlePath {string} JS bundle from the target binary to align against
116
+ * @param projectRoot {string} Root directory of the target app project, used to locate its Hermes compiler
117
+ * @return {string[]} Flags to pass to `runHermesEmitBinaryCommand` as `extraHermesFlags`
118
+ */
119
+ export function resolveBaseBytecodeHermesFlags(baseBundlePath, projectRoot = process.cwd()) {
120
+ if (!hermesSupportsBaseBytecode(projectRoot)) {
121
+ console.warn(`warn: The Hermes compiler of this app does not support "${BASE_BYTECODE_FLAG}". ` +
122
+ 'Compiling without it, which makes the binary patch larger.');
123
+ return [];
124
+ }
125
+ return [BASE_BYTECODE_FLAG, baseBundlePath];
126
+ }
127
+ /** Whether a `hermesc --help` output advertises the base bytecode flag. */
128
+ export function helpOutputSupportsBaseBytecode(helpOutput) {
129
+ return helpOutput.includes(BASE_BYTECODE_FLAG);
130
+ }
131
+ function hermesSupportsBaseBytecode(projectRoot) {
132
+ const hermesCommand = getHermesCommand(projectRoot);
133
+ const result = childProcess.spawnSync(hermesCommand, ['--help'], { encoding: 'utf8' });
134
+ if (result.error) {
135
+ throw new Error(`failed to run "${hermesCommand} --help": ${result.error.message}`);
136
+ }
137
+ return helpOutputSupportsBaseBytecode(`${result.stdout ?? ''}${result.stderr ?? ''}`);
138
+ }
92
139
  function getHermesCommand(projectRoot) {
93
140
  const fileExists = (file) => {
94
141
  try {