@hot-updater/react-native 0.28.0 → 0.29.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 (73) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +156 -7
  2. package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
  3. package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
  4. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +12 -0
  6. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
  7. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
  8. package/android/src/newarch/HotUpdaterModule.kt +16 -19
  9. package/android/src/oldarch/HotUpdaterModule.kt +20 -20
  10. package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
  11. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +153 -31
  12. package/ios/HotUpdater/Internal/CohortService.swift +63 -0
  13. package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
  14. package/ios/HotUpdater/Internal/HotUpdater.mm +111 -59
  15. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +28 -0
  16. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
  17. package/lib/commonjs/DefaultResolver.js +3 -5
  18. package/lib/commonjs/DefaultResolver.js.map +1 -1
  19. package/lib/commonjs/checkForUpdate.js +2 -0
  20. package/lib/commonjs/checkForUpdate.js.map +1 -1
  21. package/lib/commonjs/index.js +13 -0
  22. package/lib/commonjs/index.js.map +1 -1
  23. package/lib/commonjs/native.js +193 -18
  24. package/lib/commonjs/native.js.map +1 -1
  25. package/lib/commonjs/native.spec.js +361 -4
  26. package/lib/commonjs/native.spec.js.map +1 -1
  27. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  28. package/lib/commonjs/types.js.map +1 -1
  29. package/lib/module/DefaultResolver.js +3 -5
  30. package/lib/module/DefaultResolver.js.map +1 -1
  31. package/lib/module/checkForUpdate.js +3 -1
  32. package/lib/module/checkForUpdate.js.map +1 -1
  33. package/lib/module/index.js +14 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/native.js +187 -14
  36. package/lib/module/native.js.map +1 -1
  37. package/lib/module/native.spec.js +361 -4
  38. package/lib/module/native.spec.js.map +1 -1
  39. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  40. package/lib/module/types.js.map +1 -1
  41. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/index.d.ts +14 -1
  43. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/native.d.ts +39 -8
  45. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +28 -0
  47. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/types.d.ts +4 -0
  49. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/wrap.d.ts +1 -1
  51. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  52. package/lib/typescript/module/index.d.ts +14 -1
  53. package/lib/typescript/module/index.d.ts.map +1 -1
  54. package/lib/typescript/module/native.d.ts +39 -8
  55. package/lib/typescript/module/native.d.ts.map +1 -1
  56. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +28 -0
  57. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  58. package/lib/typescript/module/types.d.ts +4 -0
  59. package/lib/typescript/module/types.d.ts.map +1 -1
  60. package/lib/typescript/module/wrap.d.ts +1 -1
  61. package/package.json +6 -6
  62. package/src/DefaultResolver.ts +4 -4
  63. package/src/checkForUpdate.ts +4 -0
  64. package/src/index.ts +21 -0
  65. package/src/native.spec.ts +400 -4
  66. package/src/native.ts +265 -20
  67. package/src/specs/NativeHotUpdater.ts +32 -0
  68. package/src/types.ts +5 -0
  69. package/src/wrap.tsx +1 -1
  70. package/lib/typescript/commonjs/native.spec.d.ts +0 -2
  71. package/lib/typescript/commonjs/native.spec.d.ts.map +0 -1
  72. package/lib/typescript/module/native.spec.d.ts +0 -2
  73. package/lib/typescript/module/native.spec.d.ts.map +0 -1
package/src/native.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { UpdateStatus } from "@hot-updater/core";
1
+ import {
2
+ INVALID_COHORT_ERROR_MESSAGE,
3
+ isValidCohort,
4
+ normalizeCohortValue,
5
+ type UpdateStatus,
6
+ } from "@hot-updater/core";
2
7
  import { NativeEventEmitter, Platform } from "react-native";
3
8
  import { HotUpdaterErrorCode, isHotUpdaterError } from "./error";
4
9
  import HotUpdaterNative, {
@@ -8,6 +13,30 @@ import HotUpdaterNative, {
8
13
  export { HotUpdaterErrorCode, isHotUpdaterError };
9
14
 
10
15
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
16
+ const normalizeAndValidateCohort = (cohort: string): string => {
17
+ const normalized = normalizeCohortValue(cohort);
18
+ if (!isValidCohort(normalized)) {
19
+ throw new Error(INVALID_COHORT_ERROR_MESSAGE);
20
+ }
21
+ return normalized;
22
+ };
23
+
24
+ export interface ManifestAsset {
25
+ fileHash: string;
26
+ }
27
+
28
+ export interface Manifest {
29
+ bundleId: string;
30
+ assets: Record<string, ManifestAsset>;
31
+ }
32
+
33
+ type ActiveBundleSnapshotCacheValues = {
34
+ bundleId?: string;
35
+ manifest?: Manifest;
36
+ baseURL?: string | null;
37
+ };
38
+
39
+ type ActiveBundleSnapshotCacheKey = keyof ActiveBundleSnapshotCacheValues;
11
40
 
12
41
  /**
13
42
  * Built-in reload behaviors used by `HotUpdater.reload()`.
@@ -34,17 +63,16 @@ export type CustomReloadHandler = () => void | Promise<void>;
34
63
  */
35
64
  export type ReloadBehaviorSetting = ReloadBehavior | "custom";
36
65
 
37
- declare const __HOT_UPDATER_BUNDLE_ID: string | undefined;
38
-
39
- export const HotUpdaterConstants = {
40
- HOT_UPDATER_BUNDLE_ID: __HOT_UPDATER_BUNDLE_ID || NIL_UUID,
41
- };
42
-
43
66
  class HotUpdaterSessionState {
44
67
  private readonly defaultChannel: string;
45
68
  private currentChannel: string;
69
+ private cachedCohort: string | undefined;
46
70
  private readonly inflightUpdates = new Map<string, Promise<boolean>>();
47
71
  private lastInstalledBundleId: string | null = null;
72
+ private readonly activeBundleSnapshotCache = new Map<
73
+ ActiveBundleSnapshotCacheKey,
74
+ ActiveBundleSnapshotCacheValues[ActiveBundleSnapshotCacheKey]
75
+ >();
48
76
 
49
77
  constructor() {
50
78
  const constants = HotUpdaterNative.getConstants();
@@ -85,12 +113,66 @@ class HotUpdaterSessionState {
85
113
  if (channel) {
86
114
  this.currentChannel = channel;
87
115
  }
116
+ this.clearActiveBundleSnapshotCache();
88
117
  }
89
118
 
90
119
  resetChannelState() {
91
120
  this.currentChannel = this.defaultChannel;
92
121
  this.lastInstalledBundleId = null;
93
122
  this.inflightUpdates.clear();
123
+ this.clearActiveBundleSnapshotCache();
124
+ }
125
+
126
+ getCachedBundleId(): string | undefined {
127
+ return this.getActiveBundleSnapshotValue("bundleId");
128
+ }
129
+
130
+ cacheBundleId(bundleId: string) {
131
+ this.setActiveBundleSnapshotValue("bundleId", bundleId);
132
+ }
133
+
134
+ getCachedManifest(): Manifest | undefined {
135
+ const manifest = this.getActiveBundleSnapshotValue("manifest");
136
+ return manifest ? cloneManifest(manifest) : undefined;
137
+ }
138
+
139
+ cacheManifest(manifest: Manifest) {
140
+ this.setActiveBundleSnapshotValue("manifest", cloneManifest(manifest));
141
+ }
142
+
143
+ getCachedBaseURL(): string | null | undefined {
144
+ return this.getActiveBundleSnapshotValue("baseURL");
145
+ }
146
+
147
+ cacheBaseURL(baseURL: string | null) {
148
+ this.setActiveBundleSnapshotValue("baseURL", baseURL);
149
+ }
150
+
151
+ getCachedCohort(): string | undefined {
152
+ return this.cachedCohort;
153
+ }
154
+
155
+ cacheCohort(cohort: string) {
156
+ this.cachedCohort = cohort;
157
+ }
158
+
159
+ private clearActiveBundleSnapshotCache() {
160
+ this.activeBundleSnapshotCache.clear();
161
+ }
162
+
163
+ private getActiveBundleSnapshotValue<K extends ActiveBundleSnapshotCacheKey>(
164
+ key: K,
165
+ ): ActiveBundleSnapshotCacheValues[K] | undefined {
166
+ return this.activeBundleSnapshotCache.get(key) as
167
+ | ActiveBundleSnapshotCacheValues[K]
168
+ | undefined;
169
+ }
170
+
171
+ private setActiveBundleSnapshotValue<K extends ActiveBundleSnapshotCacheKey>(
172
+ key: K,
173
+ value: ActiveBundleSnapshotCacheValues[K],
174
+ ) {
175
+ this.activeBundleSnapshotCache.set(key, value);
94
176
  }
95
177
  }
96
178
 
@@ -98,6 +180,16 @@ const sessionState = new HotUpdaterSessionState();
98
180
  let reloadBehavior: ReloadBehaviorSetting = "processRestart";
99
181
  let customReloadHandler: CustomReloadHandler | null = null;
100
182
 
183
+ const cloneManifest = (manifest: Manifest): Manifest => ({
184
+ bundleId: manifest.bundleId,
185
+ assets: Object.fromEntries(
186
+ Object.entries(manifest.assets).map(([key, asset]) => [
187
+ key,
188
+ { fileHash: asset.fileHash },
189
+ ]),
190
+ ),
191
+ });
192
+
101
193
  const getReloadProcess = (): (() => Promise<void>) | null => {
102
194
  const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
103
195
  reloadProcess?: () => Promise<void>;
@@ -318,13 +410,72 @@ export const getMinBundleId = (): string => {
318
410
  /**
319
411
  * Fetches the current bundle version id.
320
412
  *
321
- * @async
322
- * @returns {string} Resolves with the current version id or null if not available.
413
+ * JS falls back to MIN_BUNDLE_ID only after native confirms there is no active
414
+ * downloaded bundle. When the native module does not expose `getBundleId()`,
415
+ * treat it as a JS/native SDK mismatch instead of silently reporting the
416
+ * built-in bundle.
417
+ *
418
+ * @returns {string} Resolves with the current version id.
323
419
  */
324
420
  export const getBundleId = (): string => {
325
- return HotUpdaterConstants.HOT_UPDATER_BUNDLE_ID === NIL_UUID
326
- ? getMinBundleId()
327
- : HotUpdaterConstants.HOT_UPDATER_BUNDLE_ID;
421
+ const cachedBundleId = sessionState.getCachedBundleId();
422
+ if (cachedBundleId !== undefined) {
423
+ return cachedBundleId;
424
+ }
425
+
426
+ const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
427
+ getBundleId?: () => string | null;
428
+ };
429
+
430
+ if (typeof nativeModule.getBundleId !== "function") {
431
+ throw new Error(
432
+ "[HotUpdater] Native module is missing 'getBundleId()'. This JS bundle requires a newer native @hot-updater/react-native SDK. Rebuild and release a new app version before delivering this OTA update.",
433
+ );
434
+ }
435
+
436
+ const bundleId = nativeModule.getBundleId();
437
+
438
+ const resolvedBundleId =
439
+ !bundleId || bundleId === NIL_UUID ? getMinBundleId() : bundleId;
440
+
441
+ sessionState.cacheBundleId(resolvedBundleId);
442
+ return resolvedBundleId;
443
+ };
444
+
445
+ /**
446
+ * Fetches the current manifest for the active bundle.
447
+ *
448
+ * Returns a normalized manifest with empty assets when manifest.json is missing
449
+ * or invalid.
450
+ */
451
+ export const getManifest = (): Manifest => {
452
+ const cachedManifest = sessionState.getCachedManifest();
453
+ if (cachedManifest !== undefined) {
454
+ return cachedManifest;
455
+ }
456
+
457
+ const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
458
+ getManifest?: () => Record<string, unknown> | string;
459
+ };
460
+ const manifest = nativeModule.getManifest?.();
461
+
462
+ let normalizedManifest: Manifest;
463
+
464
+ if (!manifest) {
465
+ normalizedManifest = createEmptyManifest();
466
+ } else if (typeof manifest === "string") {
467
+ try {
468
+ normalizedManifest = normalizeManifest(JSON.parse(manifest));
469
+ } catch {
470
+ normalizedManifest = createEmptyManifest();
471
+ }
472
+ } else {
473
+ normalizedManifest = normalizeManifest(manifest);
474
+ }
475
+
476
+ sessionState.cacheBundleId(normalizedManifest.bundleId);
477
+ sessionState.cacheManifest(normalizedManifest);
478
+ return cloneManifest(normalizedManifest);
328
479
  };
329
480
 
330
481
  /**
@@ -383,14 +534,16 @@ export type NotifyAppReadyResult = {
383
534
  * const result = HotUpdater.notifyAppReady();
384
535
  *
385
536
  * if (result.status === "RECOVERED") {
386
- * // Send ROLLBACK analytics event
387
- * analytics.track('bundle_rollback', { crashedBundleId: result.crashedBundleId });
537
+ * // Send ROLLBACK analytics event
538
+ * analytics.track("bundle_rollback", {
539
+ * crashedBundleId: result.crashedBundleId,
540
+ * });
388
541
  * }
389
542
  * ```
390
543
  */
391
544
  export const notifyAppReady = (): NotifyAppReadyResult => {
392
545
  const result = HotUpdaterNative.notifyAppReady();
393
- // Oldarch returns JSON string, newarch returns array
546
+ // Older Android old-arch implementations returned JSON strings.
394
547
  if (typeof result === "string") {
395
548
  try {
396
549
  return normalizeNotifyAppReadyResult(JSON.parse(result));
@@ -414,6 +567,65 @@ const normalizeNotifyAppReadyResult = (
414
567
  return { status: "STABLE" };
415
568
  };
416
569
 
570
+ const createEmptyManifest = (): Manifest => ({
571
+ bundleId: getBundleId(),
572
+ assets: {},
573
+ });
574
+
575
+ const normalizeManifest = (value: unknown): Manifest => {
576
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
577
+ return createEmptyManifest();
578
+ }
579
+
580
+ const bundleIdValue = (value as { bundleId?: unknown }).bundleId;
581
+ const bundleId =
582
+ typeof bundleIdValue === "string" && bundleIdValue.trim()
583
+ ? bundleIdValue.trim()
584
+ : getBundleId();
585
+
586
+ return {
587
+ bundleId,
588
+ assets: normalizeManifestAssets((value as { assets?: unknown }).assets),
589
+ };
590
+ };
591
+
592
+ const normalizeManifestAssets = (value: unknown): Manifest["assets"] => {
593
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
594
+ return {};
595
+ }
596
+
597
+ return Object.fromEntries(
598
+ Object.entries(value).flatMap(([key, entry]) => {
599
+ const trimmedKey = key.trim();
600
+
601
+ if (!trimmedKey) {
602
+ return [];
603
+ }
604
+
605
+ if (typeof entry === "string") {
606
+ const fileHash = entry.trim();
607
+
608
+ if (!fileHash) {
609
+ return [];
610
+ }
611
+
612
+ return [[trimmedKey, { fileHash }] as const];
613
+ }
614
+
615
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
616
+ return [];
617
+ }
618
+
619
+ const { fileHash } = entry as { fileHash?: unknown };
620
+ if (typeof fileHash !== "string" || !fileHash.trim()) {
621
+ return [];
622
+ }
623
+
624
+ return [[trimmedKey, { fileHash: fileHash.trim() }] as const];
625
+ }),
626
+ );
627
+ };
628
+
417
629
  /**
418
630
  * Gets the list of bundle IDs that have been marked as crashed.
419
631
  * These bundles will be rejected if attempted to install again.
@@ -422,7 +634,7 @@ const normalizeNotifyAppReadyResult = (
422
634
  */
423
635
  export const getCrashHistory = (): string[] => {
424
636
  const result = HotUpdaterNative.getCrashHistory();
425
- // Oldarch returns JSON string, newarch returns array
637
+ // Older Android old-arch implementations returned JSON strings.
426
638
  if (typeof result === "string") {
427
639
  try {
428
640
  return JSON.parse(result);
@@ -451,11 +663,15 @@ export const clearCrashHistory = (): boolean => {
451
663
  * @returns {string | null} Base URL string (e.g., "file:///data/.../bundle-store/abc123") or null if not available
452
664
  */
453
665
  export const getBaseURL = (): string | null => {
454
- const result = HotUpdaterNative.getBaseURL();
455
- if (typeof result === "string" && result !== "") {
456
- return result;
666
+ const cachedBaseURL = sessionState.getCachedBaseURL();
667
+ if (cachedBaseURL !== undefined) {
668
+ return cachedBaseURL;
457
669
  }
458
- return null;
670
+
671
+ const result = HotUpdaterNative.getBaseURL();
672
+ const baseURL = typeof result === "string" && result !== "" ? result : null;
673
+ sessionState.cacheBaseURL(baseURL);
674
+ return baseURL;
459
675
  };
460
676
 
461
677
  /**
@@ -472,3 +688,32 @@ export const resetChannel = async (): Promise<boolean> => {
472
688
  }
473
689
  return ok;
474
690
  };
691
+
692
+ /**
693
+ * Sets the persisted cohort used for update checks.
694
+ *
695
+ * HotUpdater only derives a device-based cohort when nothing has been stored
696
+ * yet. If you need to restore that initial value later, read it with
697
+ * `getCohort()` before calling `setCohort()`, then store it yourself.
698
+ */
699
+ export const setCohort = (cohort: string): void => {
700
+ const normalized = normalizeAndValidateCohort(cohort);
701
+ HotUpdaterNative.setCohort(normalized);
702
+ sessionState.cacheCohort(normalized);
703
+ };
704
+
705
+ /**
706
+ * Gets the persisted cohort used for rollout calculations.
707
+ * If none has been stored yet, native derives the initial value once and
708
+ * persists it before returning.
709
+ */
710
+ export const getCohort = (): string => {
711
+ const cachedCohort = sessionState.getCachedCohort();
712
+ if (cachedCohort !== undefined) {
713
+ return cachedCohort;
714
+ }
715
+
716
+ const cohort = normalizeAndValidateCohort(HotUpdaterNative.getCohort());
717
+ sessionState.cacheCohort(cohort);
718
+ return cohort;
719
+ };
@@ -1,5 +1,6 @@
1
1
  import type { TurboModule } from "react-native";
2
2
  import { TurboModuleRegistry } from "react-native";
3
+ import type { UnsafeObject } from "react-native/Libraries/Types/CodegenTypes";
3
4
 
4
5
  export interface UpdateBundleParams {
5
6
  bundleId: string;
@@ -105,6 +106,37 @@ export interface Spec extends TurboModule {
105
106
  */
106
107
  getBaseURL: () => string;
107
108
 
109
+ /**
110
+ * Gets the current active bundle ID from native bundle storage.
111
+ * Native reads the extracted bundle manifest first and falls back to the
112
+ * legacy BUNDLE_ID file when needed. Built-in bundle fallback is handled in JS.
113
+ *
114
+ * @returns Active bundle ID from bundle storage, or null when unavailable
115
+ */
116
+ getBundleId: () => string | null;
117
+
118
+ /**
119
+ * Gets the current manifest from native bundle storage.
120
+ * Returns an empty object when manifest.json is missing or invalid.
121
+ */
122
+ getManifest: () => UnsafeObject;
123
+
124
+ /**
125
+ * Sets the persisted cohort used for rollout calculations.
126
+ *
127
+ * Native only derives a device-based cohort when nothing has been stored
128
+ * yet. Call `getCohort()` first if the app needs to save that initial value
129
+ * for a later restore.
130
+ */
131
+ setCohort: (cohort: string) => void;
132
+
133
+ /**
134
+ * Gets the persisted cohort used for rollout calculations.
135
+ * If none has been stored yet, native derives the initial value once and
136
+ * persists it before returning.
137
+ */
138
+ getCohort: () => string;
139
+
108
140
  // EventEmitter
109
141
  addListener(eventName: string): void;
110
142
  removeListeners(count: number): void;
package/src/types.ts CHANGED
@@ -30,6 +30,11 @@ export interface ResolverCheckUpdateParams {
30
30
  */
31
31
  channel: string;
32
32
 
33
+ /**
34
+ * Cohort identifier used for server-side rollout decisions.
35
+ */
36
+ cohort: string;
37
+
33
38
  /**
34
39
  * Update strategy being used
35
40
  */
package/src/wrap.tsx CHANGED
@@ -55,7 +55,7 @@ interface CommonHotUpdaterOptions {
55
55
  * updateMode: "manual",
56
56
  * onNotifyAppReady: ({ status, crashedBundleId }) => {
57
57
  * if (status === "RECOVERED") {
58
- * analytics.track('bundle_rollback', { crashedBundleId });
58
+ * analytics.track("bundle_rollback", { crashedBundleId });
59
59
  * }
60
60
  * }
61
61
  * })(App);
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=native.spec.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"native.spec.d.ts","sourceRoot":"","sources":["../../../src/native.spec.ts"],"names":[],"mappings":""}
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=native.spec.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"native.spec.d.ts","sourceRoot":"","sources":["../../../src/native.spec.ts"],"names":[],"mappings":""}