@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,367 @@
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
+
17
+ import crypto from "crypto";
18
+ import fs from "fs";
19
+ import path from "path";
20
+ import shell from "shelljs";
21
+ import {
22
+ BINARY_PATCH_ALGORITHM,
23
+ BINARY_PATCH_FORMAT_VERSION,
24
+ generatePatch,
25
+ } from "../utils/binaryPatch.js";
26
+ import { unzip } from "../utils/unzip.js";
27
+ import { zip } from "../utils/zip.js";
28
+
29
+ /** Manifest file a client reads to decide whether it can apply the patch, and how. */
30
+ export const BINARY_PATCH_MANIFEST_NAME = 'codepush-binary-patch.json';
31
+
32
+ /** Appended to the full archive name so the two artifacts of a release stay paired. */
33
+ export const BINARY_PATCH_ARCHIVE_SUFFIX = '-patch.zip';
34
+
35
+ /**
36
+ * Record the `bundle` command leaves in the output root - outside the update contents,
37
+ * so it never reaches the archive - to say which base bundle the JS bundle was
38
+ * compiled against. A later `release` compares it with the base it was given.
39
+ *
40
+ * The build hooks (`android/codepush-export.gradle`, `scripts/export-embedded-bundle.sh`)
41
+ * write a record of the same name next to the bundle they export from a native build,
42
+ * which is what makes that bundle self-describing when it is later passed to
43
+ * `release --binary-bundle-path`.
44
+ */
45
+ export const BINARY_PATCH_BASE_RECORD_NAME = 'binary-patch-base.json';
46
+
47
+ const TEMP_PATCH_CONTENTS_DIR_NAME = 'temp_contents_for_binary_patch';
48
+ const TEMP_EXTRACTED_CONTENTS_DIR_NAME = 'temp_contents_from_bundle_file';
49
+
50
+ const HASH_ALGORITHM = 'sha256';
51
+
52
+ export type BinaryPatchManifest = {
53
+ formatVersion: number;
54
+ algorithm: string;
55
+ /** Target bundle path, relative to the update contents root. */
56
+ bundlePath: string;
57
+ /** Patch file path, relative to the update contents root. */
58
+ patchFile: string;
59
+ baseBundleHash: string;
60
+ targetBundleHash: string;
61
+ targetBundleSize: number;
62
+ };
63
+
64
+ export type BinaryPatchBundle = {
65
+ patchBundleFilePath: string;
66
+ manifest: BinaryPatchManifest;
67
+ };
68
+
69
+ /**
70
+ * Contents of `binary-patch-base.json`. The `bundle` command writes the hash alone; a
71
+ * build hook, which knows which binary the bundle it exported went into, adds the rest.
72
+ *
73
+ * The field names are a contract shared with `android/codepush-export.gradle` and
74
+ * `scripts/export-embedded-bundle.sh`, which spell them out as literals. Keep the three
75
+ * in step.
76
+ */
77
+ export type BinaryPatchBaseRecord = {
78
+ baseBundleHash: string;
79
+ /** Marketing version of the binary the bundle shipped in: `versionName` / `CFBundleShortVersionString`. */
80
+ binaryVersion?: string;
81
+ /** Build number of that binary: `versionCode` / `CFBundleVersion`. */
82
+ buildNumber?: string;
83
+ /** Commit the binary was built from, present only when the build could work it out. */
84
+ gitSha?: string;
85
+ };
86
+
87
+ /** SHA-256 of a single file's bytes, which is what the manifest records. */
88
+ export function hashBundleFile(filePath: string): string {
89
+ return crypto.createHash(HASH_ALGORITHM).update(fs.readFileSync(filePath)).digest('hex');
90
+ }
91
+
92
+ /**
93
+ * Resolves the `--binary-bundle-path` option to an absolute path, rejecting anything
94
+ * that is not an existing file. Called before the bundler runs so a typo surfaces
95
+ * before minutes of bundling rather than after.
96
+ */
97
+ export function resolveBaseBundlePath(binaryBundlePath: string): string {
98
+ const resolved = path.resolve(binaryBundlePath);
99
+
100
+ let stats: fs.Stats;
101
+ try {
102
+ stats = fs.statSync(resolved);
103
+ } catch {
104
+ throw new Error(`--binary-bundle-path "${binaryBundlePath}" does not exist.`);
105
+ }
106
+ if (!stats.isFile()) {
107
+ throw new Error(`--binary-bundle-path "${binaryBundlePath}" is not a file.`);
108
+ }
109
+
110
+ return resolved;
111
+ }
112
+
113
+ /**
114
+ * Same as `resolveBaseBundlePath`, but reports the problem the way the other CLI
115
+ * options do. Returns `undefined` when the option was not passed at all.
116
+ */
117
+ export function resolveBinaryBundlePathOption(binaryBundlePath: string | undefined): string | undefined {
118
+ if (!binaryBundlePath) {
119
+ return undefined;
120
+ }
121
+
122
+ try {
123
+ return resolveBaseBundlePath(binaryBundlePath);
124
+ } catch (error) {
125
+ console.error(error instanceof Error ? error.message : error);
126
+ process.exit(1);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Creates the patch archive for an update that has already been assembled.
132
+ *
133
+ * @param contentsPath {string} Directory holding the update contents that went into the full archive
134
+ * @param baseBundlePath {string} JS bundle from the target binary, which the patch is computed against
135
+ * @param bundleRelativePath {string} Target bundle path inside `contentsPath`
136
+ * @param bundleDirectory {string} Directory the full archive was written to
137
+ * @param packageHash {string} Package hash of the full archive, which names both artifacts
138
+ */
139
+ export async function makeBinaryPatchBundle({
140
+ contentsPath,
141
+ baseBundlePath,
142
+ bundleRelativePath,
143
+ bundleDirectory,
144
+ packageHash,
145
+ }: {
146
+ contentsPath: string;
147
+ baseBundlePath: string;
148
+ bundleRelativePath: string;
149
+ bundleDirectory: string;
150
+ packageHash: string;
151
+ }): Promise<BinaryPatchBundle> {
152
+ const resolvedContentsPath = path.resolve(contentsPath);
153
+ const targetBundlePath = path.join(resolvedContentsPath, bundleRelativePath);
154
+ if (!isFile(targetBundlePath)) {
155
+ throw new Error(
156
+ `Target bundle "${bundleRelativePath}" was not found in the update contents ("${resolvedContentsPath}"). ` +
157
+ `Pass -j/--js-bundle-name if the bundle file has a different name.`,
158
+ );
159
+ }
160
+ const resolvedBaseBundlePath = resolveBaseBundlePath(baseBundlePath);
161
+
162
+ const baseBundleHash = hashBundleFile(resolvedBaseBundlePath);
163
+ const targetBundleHash = hashBundleFile(targetBundlePath);
164
+ if (baseBundleHash === targetBundleHash) {
165
+ console.warn(
166
+ 'warn: The base bundle and the target bundle are identical, so the update changes nothing. Releasing anyway.',
167
+ );
168
+ }
169
+
170
+ // Kept next to the update contents rather than in the system temp directory so a
171
+ // crash leaves the leftovers where the rest of the build output is cleaned up.
172
+ const tempRoot = path.join(path.dirname(resolvedContentsPath), TEMP_PATCH_CONTENTS_DIR_NAME);
173
+ // The archive root directory name is part of the contents layout, so the patch
174
+ // archive has to reuse the name the full archive used.
175
+ const patchContentsPath = path.join(tempRoot, path.basename(resolvedContentsPath));
176
+
177
+ fs.rmSync(tempRoot, { recursive: true, force: true });
178
+
179
+ try {
180
+ fs.cpSync(resolvedContentsPath, patchContentsPath, { recursive: true });
181
+ fs.rmSync(path.join(patchContentsPath, bundleRelativePath));
182
+
183
+ const patchRelativePath = `${bundleRelativePath}.patch`;
184
+ generatePatch(resolvedBaseBundlePath, targetBundlePath, path.join(patchContentsPath, patchRelativePath));
185
+
186
+ const manifest: BinaryPatchManifest = {
187
+ formatVersion: BINARY_PATCH_FORMAT_VERSION,
188
+ algorithm: BINARY_PATCH_ALGORITHM,
189
+ bundlePath: bundleRelativePath,
190
+ patchFile: patchRelativePath,
191
+ baseBundleHash,
192
+ targetBundleHash,
193
+ targetBundleSize: fs.statSync(targetBundlePath).size,
194
+ };
195
+ fs.writeFileSync(path.join(patchContentsPath, BINARY_PATCH_MANIFEST_NAME), JSON.stringify(manifest, null, 2));
196
+
197
+ const patchArchiveZipPath = await zip(patchContentsPath);
198
+ const patchBundleFilePath = path.join(bundleDirectory, `${packageHash}${BINARY_PATCH_ARCHIVE_SUFFIX}`);
199
+ shell.mkdir('-p', bundleDirectory);
200
+ shell.mv(patchArchiveZipPath, patchBundleFilePath);
201
+
202
+ return { patchBundleFilePath, manifest };
203
+ } finally {
204
+ fs.rmSync(tempRoot, { recursive: true, force: true });
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Unpacks an already built CodePush bundle file, so `release --skip-bundle` can patch
210
+ * exactly the contents that were packed instead of bundling them again.
211
+ *
212
+ * @return The extraction directory, which the caller owns and must remove, and the
213
+ * update contents root inside it.
214
+ */
215
+ export async function extractCodePushBundleContents(
216
+ bundleFilePath: string,
217
+ parentDirectory: string,
218
+ ): Promise<{ extractDir: string, contentsPath: string }> {
219
+ const extractDir = path.resolve(path.join(parentDirectory, TEMP_EXTRACTED_CONTENTS_DIR_NAME));
220
+
221
+ fs.rmSync(extractDir, { recursive: true, force: true });
222
+ fs.mkdirSync(extractDir, { recursive: true });
223
+ try {
224
+ await unzip(path.resolve(bundleFilePath), extractDir);
225
+ } catch (error) {
226
+ fs.rmSync(extractDir, { recursive: true, force: true });
227
+ throw error;
228
+ }
229
+
230
+ return { extractDir, contentsPath: resolveExtractedContentsPath(extractDir) };
231
+ }
232
+
233
+ /**
234
+ * A CodePush archive wraps its files in a single directory, so that directory - not
235
+ * the extraction directory - is the contents root.
236
+ */
237
+ function resolveExtractedContentsPath(extractDir: string): string {
238
+ const entries = fs.readdirSync(extractDir, { withFileTypes: true });
239
+ if (entries.length === 1 && entries[0].isDirectory()) {
240
+ return path.join(extractDir, entries[0].name);
241
+ }
242
+ return extractDir;
243
+ }
244
+
245
+ /** Records which base bundle the JS bundle in `outputRootPath` was compiled against. */
246
+ export function writeBinaryPatchBaseRecord(outputRootPath: string, baseBundleHash: string): string {
247
+ const recordPath = path.join(outputRootPath, BINARY_PATCH_BASE_RECORD_NAME);
248
+ const record: BinaryPatchBaseRecord = { baseBundleHash };
249
+
250
+ fs.mkdirSync(outputRootPath, { recursive: true });
251
+ fs.writeFileSync(recordPath, JSON.stringify(record, null, 2));
252
+
253
+ return recordPath;
254
+ }
255
+
256
+ /**
257
+ * Reads the record left by a previous `bundle` run. An unreadable record is treated as
258
+ * no record: it only feeds a warning, and must never be able to fail a release.
259
+ */
260
+ export function readBinaryPatchBaseRecord(outputRootPath: string): BinaryPatchBaseRecord | null {
261
+ const recordPath = path.join(outputRootPath, BINARY_PATCH_BASE_RECORD_NAME);
262
+
263
+ try {
264
+ const record = JSON.parse(fs.readFileSync(recordPath, 'utf8')) as Partial<BinaryPatchBaseRecord>;
265
+ if (typeof record.baseBundleHash !== 'string') {
266
+ return null;
267
+ }
268
+ return { baseBundleHash: record.baseBundleHash };
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * What a release does with a patch archive that did not turn out smaller than the full
276
+ * archive. The CLI runs unattended in CI, so the answer is a policy chosen up front
277
+ * rather than a decision the summary invites someone to make.
278
+ */
279
+ export type OversizedPatchPolicy = 'skip' | 'fail';
280
+
281
+ /** Accepted `--on-oversized-patch` values, in the order the help text lists them. */
282
+ export const OVERSIZED_PATCH_POLICIES: OversizedPatchPolicy[] = ['skip', 'fail'];
283
+
284
+ export const DEFAULT_OVERSIZED_PATCH_POLICY: OversizedPatchPolicy = 'skip';
285
+
286
+ /**
287
+ * Whether the patch archive is worth publishing next to the full archive.
288
+ *
289
+ * Equal sizes count as oversized: a patch that saves nothing still costs a client the
290
+ * download plus an apply step, so it is never the better artifact of the two.
291
+ */
292
+ export function isPatchArchiveOversized(fullArchiveSize: number, patchArchiveSize: number): boolean {
293
+ return patchArchiveSize >= fullArchiveSize;
294
+ }
295
+
296
+ /**
297
+ * The operator-facing summary, printed before the artifacts are uploaded so the size of
298
+ * what is about to be published is on the record. When the patch is not smaller than the
299
+ * full archive and the release is going ahead without it, the summary says so rather
300
+ * than leaving a negative saving to be interpreted.
301
+ */
302
+ export function formatBinaryPatchSummary({
303
+ platform,
304
+ baseBundleHash,
305
+ targetBundleHash,
306
+ fullArchiveSize,
307
+ patchArchiveSize,
308
+ patchSkipped = false,
309
+ }: {
310
+ platform: 'ios' | 'android';
311
+ baseBundleHash: string;
312
+ targetBundleHash: string;
313
+ fullArchiveSize: number;
314
+ patchArchiveSize: number;
315
+ patchSkipped?: boolean;
316
+ }): string {
317
+ const savedBytes = fullArchiveSize - patchArchiveSize;
318
+ const savedRatio = fullArchiveSize > 0 ? savedBytes / fullArchiveSize : 0;
319
+
320
+ const sizes = [fullArchiveSize, patchArchiveSize, savedBytes].map(formatBytes);
321
+ const sizeWidth = Math.max(...sizes.map((size) => size.length));
322
+ const [fullSize, patchSize, savedSize] = sizes.map((size) => size.padStart(sizeWidth));
323
+
324
+ const label = (text: string) => text.padEnd(23);
325
+
326
+ const lines = [
327
+ `Binary patch summary (${platform})`,
328
+ `${label('Base bundle SHA-256:')}${baseBundleHash}`,
329
+ `${label('Target bundle SHA-256:')}${targetBundleHash}`,
330
+ `${label('Full archive:')}${fullSize}`,
331
+ `${label('Patch archive:')}${patchSize}`,
332
+ `${label('Saved:')}${savedSize} (${(savedRatio * 100).toFixed(1)}%)`,
333
+ ];
334
+
335
+ if (patchSkipped) {
336
+ lines.push(
337
+ `${label('Patch skipped:')}not smaller than the full archive; releasing the full bundle only (--on-oversized-patch skip)`,
338
+ );
339
+ }
340
+
341
+ return lines.join('\n');
342
+ }
343
+
344
+ function formatBytes(bytes: number): string {
345
+ const units = ['KB', 'MB', 'GB', 'TB'];
346
+
347
+ if (Math.abs(bytes) < 1024) {
348
+ return `${bytes} B`;
349
+ }
350
+
351
+ let value = bytes / 1024;
352
+ let unitIndex = 0;
353
+ while (Math.abs(value) >= 1024 && unitIndex < units.length - 1) {
354
+ value /= 1024;
355
+ unitIndex++;
356
+ }
357
+
358
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
359
+ }
360
+
361
+ function isFile(filePath: string): boolean {
362
+ try {
363
+ return fs.statSync(filePath).isFile();
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
@@ -15,8 +15,10 @@ export async function makeCodePushBundle(contentsPath: string, bundleDirectory:
15
15
 
16
16
  const packageHash = await generatePackageHashFromDirectory(contentsPath, path.join(contentsPath, '..'));
17
17
 
18
- shell.mkdir('-p', `./${bundleDirectory}`);
19
- shell.mv(updateContentsZipPath, `./${bundleDirectory}/${packageHash}`);
18
+ // Joined rather than interpolated so an absolute bundle directory - which an
19
+ // absolute --output-path produces - is not turned into a path below the cwd.
20
+ shell.mkdir('-p', bundleDirectory);
21
+ shell.mv(updateContentsZipPath, path.join(bundleDirectory, packageHash));
20
22
 
21
23
  return {
22
24
  // To allow the "release" command to get the file and hash value from the result of the "bundle" command,
@@ -0,0 +1,124 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals";
5
+ import {
6
+ helpOutputSupportsBaseBytecode,
7
+ resolveBaseBytecodeHermesFlags,
8
+ } from "./runHermesEmitBinaryCommand.js";
9
+
10
+ /**
11
+ * The base bytecode flag only exists in recent Hermes compilers, and the compiler that
12
+ * matters is the one inside the app being released - so the flag is decided by asking
13
+ * that binary, not by a version check.
14
+ */
15
+
16
+ /** Excerpt of `hermesc --help` from a compiler that has the flag. */
17
+ const HELP_WITH_BASE_BYTECODE = `OVERVIEW: Hermes driver
18
+
19
+ USAGE: hermesc [options] <input>
20
+
21
+ OPTIONS:
22
+ -Wno-direct-eval - Disable Warning when attempting a direct (local) eval
23
+ -base-bytecode=<string> - input base bytecode for delta optimizing mode
24
+ -bytecode-output-manifest=<string> - Name of the manifest file generated when compiling multiple segments to bytecode
25
+ -commonjs - Use CommonJS modules
26
+ `;
27
+
28
+ /** The same excerpt from a compiler that predates the flag. */
29
+ const HELP_WITHOUT_BASE_BYTECODE = `OVERVIEW: Hermes driver
30
+
31
+ USAGE: hermesc [options] <input>
32
+
33
+ OPTIONS:
34
+ -Wno-direct-eval - Disable Warning when attempting a direct (local) eval
35
+ -bytecode-output-manifest=<string> - Name of the manifest file generated when compiling multiple segments to bytecode
36
+ -commonjs - Use CommonJS modules
37
+ `;
38
+
39
+ function findRepoRoot(): string {
40
+ let dir = process.cwd();
41
+ for (;;) {
42
+ if (fs.existsSync(path.join(dir, "node_modules", "react-native"))) {
43
+ return dir;
44
+ }
45
+ const parent = path.dirname(dir);
46
+ if (parent === dir) {
47
+ throw new Error(`cannot locate the repository root from ${process.cwd()}`);
48
+ }
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ function hermesOSBinDirName(): string {
54
+ switch (process.platform) {
55
+ case 'win32':
56
+ return 'win64-bin';
57
+ case 'darwin':
58
+ return 'osx-bin';
59
+ default:
60
+ return 'linux64-bin';
61
+ }
62
+ }
63
+
64
+ const repoRoot = findRepoRoot();
65
+ const baseBundlePath = path.join(repoRoot, "cli", "fixtures", "binary-patch", "base.bundle");
66
+
67
+ let workDir: string;
68
+
69
+ /** A project whose Hermes compiler reports a help text without the flag. */
70
+ function writeProjectWithFakeHermesc(helpOutput: string): string {
71
+ const projectRoot = fs.mkdtempSync(path.join(workDir, "project-"));
72
+ const hermescDir = path.join(projectRoot, "node_modules", "react-native", "sdks", "hermesc", hermesOSBinDirName());
73
+
74
+ fs.mkdirSync(hermescDir, { recursive: true });
75
+ const hermescPath = path.join(hermescDir, "hermesc");
76
+ fs.writeFileSync(hermescPath, `#!/bin/sh\ncat <<'HELP'\n${helpOutput}HELP\n`);
77
+ fs.chmodSync(hermescPath, 0o755);
78
+
79
+ return projectRoot;
80
+ }
81
+
82
+ beforeAll(() => {
83
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepush-hermes-flags-"));
84
+ });
85
+
86
+ afterAll(() => {
87
+ fs.rmSync(workDir, { recursive: true, force: true });
88
+ });
89
+
90
+ afterEach(() => {
91
+ jest.restoreAllMocks();
92
+ });
93
+
94
+ describe("helpOutputSupportsBaseBytecode", () => {
95
+ it("detects the flag in the help output of a compiler that has it", () => {
96
+ expect(helpOutputSupportsBaseBytecode(HELP_WITH_BASE_BYTECODE)).toBe(true);
97
+ });
98
+
99
+ it("reports no support when the help output does not list the flag", () => {
100
+ expect(helpOutputSupportsBaseBytecode(HELP_WITHOUT_BASE_BYTECODE)).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("resolveBaseBytecodeHermesFlags", () => {
105
+ it("aligns the compilation with the base bundle when the compiler supports it", () => {
106
+ expect(resolveBaseBytecodeHermesFlags(baseBundlePath, repoRoot)).toEqual(['-base-bytecode', baseBundlePath]);
107
+ });
108
+
109
+ const itUnlessWindows = process.platform === 'win32' ? it.skip : it;
110
+
111
+ itUnlessWindows("warns and compiles without the flag when the compiler does not support it", () => {
112
+ const projectRoot = writeProjectWithFakeHermesc(HELP_WITHOUT_BASE_BYTECODE);
113
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
114
+
115
+ expect(resolveBaseBytecodeHermesFlags(baseBundlePath, projectRoot)).toEqual([]);
116
+ expect(warn.mock.calls.join('\n')).toMatch(/-base-bytecode/);
117
+ });
118
+
119
+ itUnlessWindows("uses the flag when the app's own compiler advertises it", () => {
120
+ const projectRoot = writeProjectWithFakeHermesc(HELP_WITH_BASE_BYTECODE);
121
+
122
+ expect(resolveBaseBytecodeHermesFlags(baseBundlePath, projectRoot)).toEqual(['-base-bytecode', baseBundlePath]);
123
+ });
124
+ });
@@ -8,6 +8,12 @@ import path from "path";
8
8
  import { createRequire } from "node:module";
9
9
  import shell from "shelljs";
10
10
 
11
+ /**
12
+ * Tells Hermes to lay the compiled bytecode out like an existing bundle's, which keeps
13
+ * the binary patch between the two small. Older compilers do not have the flag.
14
+ */
15
+ const BASE_BYTECODE_FLAG = '-base-bytecode';
16
+
11
17
  /**
12
18
  * Run Hermes compile CLI command
13
19
  *
@@ -43,7 +49,16 @@ export async function runHermesEmitBinaryCommand(
43
49
  const hermesCommand = getHermesCommand(projectRoot);
44
50
 
45
51
  const disableAllWarningsArg = '-w';
46
- shell.exec(`${hermesCommand} ${hermesArgs.join(' ')} ${disableAllWarningsArg}`);
52
+ const compileResult = shell.exec(`${hermesCommand} ${hermesArgs.join(' ')} ${disableAllWarningsArg}`);
53
+ if (compileResult.code !== 0) {
54
+ // Reported here rather than left to the missing .hbc file, so a rejected
55
+ // flag - a base bundle that is not Hermes bytecode, for instance - fails
56
+ // the release instead of silently producing an unaligned bundle.
57
+ const extraFlagsHint = extraHermesFlags.length > 0
58
+ ? ` Additional options: ${extraHermesFlags.join(' ')}`
59
+ : '';
60
+ throw new Error(`"hermesc" command failed (exitCode=${compileResult.code}).${extraFlagsHint}`);
61
+ }
47
62
 
48
63
  // Copy HBC bundle to overwrite JS bundle
49
64
  const source = path.join(outputPath, bundleName + '.hbc');
@@ -109,6 +124,47 @@ export async function runHermesEmitBinaryCommand(
109
124
  });
110
125
  }
111
126
 
127
+ /**
128
+ * Builds the flags that align a compilation with the bundle already inside the app
129
+ * binary, so the binary patch between them stays small.
130
+ *
131
+ * When the app's Hermes compiler predates the flag the release still goes ahead
132
+ * without it: the patch is then computed against unaligned bytecode and is larger, but
133
+ * it is still a valid patch. A compiler that does accept the flag and fails is a
134
+ * different matter and fails the release, because the base input is then wrong.
135
+ *
136
+ * @param baseBundlePath {string} JS bundle from the target binary to align against
137
+ * @param projectRoot {string} Root directory of the target app project, used to locate its Hermes compiler
138
+ * @return {string[]} Flags to pass to `runHermesEmitBinaryCommand` as `extraHermesFlags`
139
+ */
140
+ export function resolveBaseBytecodeHermesFlags(baseBundlePath: string, projectRoot: string = process.cwd()): string[] {
141
+ if (!hermesSupportsBaseBytecode(projectRoot)) {
142
+ console.warn(
143
+ `warn: The Hermes compiler of this app does not support "${BASE_BYTECODE_FLAG}". ` +
144
+ 'Compiling without it, which makes the binary patch larger.',
145
+ );
146
+ return [];
147
+ }
148
+
149
+ return [BASE_BYTECODE_FLAG, baseBundlePath];
150
+ }
151
+
152
+ /** Whether a `hermesc --help` output advertises the base bytecode flag. */
153
+ export function helpOutputSupportsBaseBytecode(helpOutput: string): boolean {
154
+ return helpOutput.includes(BASE_BYTECODE_FLAG);
155
+ }
156
+
157
+ function hermesSupportsBaseBytecode(projectRoot: string): boolean {
158
+ const hermesCommand = getHermesCommand(projectRoot);
159
+ const result = childProcess.spawnSync(hermesCommand, ['--help'], { encoding: 'utf8' });
160
+
161
+ if (result.error) {
162
+ throw new Error(`failed to run "${hermesCommand} --help": ${result.error.message}`);
163
+ }
164
+
165
+ return helpOutputSupportsBaseBytecode(`${result.stdout ?? ''}${result.stderr ?? ''}`);
166
+ }
167
+
112
168
  function getHermesCommand(projectRoot: string): string {
113
169
  const fileExists = (file: string): boolean => {
114
170
  try {
@@ -0,0 +1,43 @@
1
+ import { spawnSync } from "child_process";
2
+ import fs from "fs";
3
+ import path from "path";
4
+
5
+ /**
6
+ * Builds hdiffz/hpatchz once, before any test worker starts.
7
+ *
8
+ * Several suites generate and apply real patches, which is the only way to prove the
9
+ * committed patch format is what every applier understands. Provisioning the tools here
10
+ * rather than in each suite keeps a clean checkout - and CI - free of a manual setup
11
+ * step, without letting two workers run the same build at the same time.
12
+ *
13
+ * The build script installs into `HDIFFPATCH_TOOLS_DIR` when it is set and into
14
+ * `<repo>/.hdiffpatch-tools` otherwise, which is where the CLI looks for the tools. It
15
+ * is a no-op when they are already there, so the check below only decides whether to
16
+ * warn that this run has a multi-minute build ahead of it.
17
+ *
18
+ * Note that this module is loaded outside the jest module resolver, so it cannot import
19
+ * from the CLI sources.
20
+ */
21
+
22
+ const REPO_ROOT = path.join(__dirname, '..');
23
+ const BUILD_SCRIPT_PATH = path.join(REPO_ROOT, 'scripts', 'binary-patch', 'build-hdiffpatch.sh');
24
+ const DEFAULT_TOOLS_DIR = path.join(REPO_ROOT, '.hdiffpatch-tools');
25
+
26
+ export default function ensureBinaryPatchTools(): void {
27
+ const toolsDir = process.env.HDIFFPATCH_TOOLS_DIR || DEFAULT_TOOLS_DIR;
28
+ const alreadyInstalled = ['hdiffz', 'hpatchz'].every((tool) => fs.existsSync(path.join(toolsDir, tool)));
29
+
30
+ if (!alreadyInstalled) {
31
+ // Announced because a first build clones and compiles HDiffPatch, which takes
32
+ // minutes; a silent wait would look like a hang.
33
+ console.log(`Building hdiffz/hpatchz into ${toolsDir} (first run only)`);
34
+ }
35
+
36
+ const result = spawnSync(BUILD_SCRIPT_PATH, { encoding: 'utf8' });
37
+ if (result.error) {
38
+ throw new Error(`failed to run ${BUILD_SCRIPT_PATH}: ${result.error.message}`);
39
+ }
40
+ if (result.status !== 0) {
41
+ throw new Error(`${BUILD_SCRIPT_PATH} failed:\n${result.stdout ?? ''}${result.stderr ?? ''}`);
42
+ }
43
+ }