@hot-updater/react-native 0.30.12 → 0.31.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 (119) hide show
  1. package/HotUpdater.podspec +1 -0
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/hotupdater/BsdiffPatch.kt +284 -0
  4. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +885 -5
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +13 -2
  6. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +37 -8
  7. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +48 -12
  8. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +60 -22
  9. package/android/src/newarch/HotUpdater.kt +14 -2
  10. package/android/src/newarch/HotUpdaterModule.kt +112 -1
  11. package/android/src/oldarch/HotUpdater.kt +14 -2
  12. package/android/src/oldarch/HotUpdaterModule.kt +107 -3
  13. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +46 -0
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +29 -1
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -1
  16. package/ios/HotUpdater/Internal/BsdiffPatchBridge.h +16 -0
  17. package/ios/HotUpdater/Internal/BsdiffPatchBridge.mm +343 -0
  18. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +1093 -47
  19. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +2 -0
  20. package/ios/HotUpdater/Internal/HotUpdater.mm +12 -2
  21. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +64 -5
  22. package/ios/HotUpdater/Internal/NotificationExtension.swift +2 -1
  23. package/ios/HotUpdater/Internal/SignatureVerifier.swift +28 -5
  24. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +11 -0
  25. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +11 -5
  26. package/lib/commonjs/DefaultResolver.js +6 -2
  27. package/lib/commonjs/DefaultResolver.js.map +1 -1
  28. package/lib/commonjs/DefaultResolver.spec.js +79 -3
  29. package/lib/commonjs/DefaultResolver.spec.js.map +1 -1
  30. package/lib/commonjs/checkForUpdate.js +6 -0
  31. package/lib/commonjs/checkForUpdate.js.map +1 -1
  32. package/lib/commonjs/index.js +53 -18
  33. package/lib/commonjs/index.js.map +1 -1
  34. package/lib/commonjs/index.spec.js +117 -1
  35. package/lib/commonjs/index.spec.js.map +1 -1
  36. package/lib/commonjs/native.js +40 -16
  37. package/lib/commonjs/native.js.map +1 -1
  38. package/lib/commonjs/native.spec.js +119 -3
  39. package/lib/commonjs/native.spec.js.map +1 -1
  40. package/lib/commonjs/sdkVersion.js +8 -0
  41. package/lib/commonjs/sdkVersion.js.map +1 -0
  42. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  43. package/lib/commonjs/store.js +72 -5
  44. package/lib/commonjs/store.js.map +1 -1
  45. package/lib/commonjs/store.spec.js +265 -8
  46. package/lib/commonjs/store.spec.js.map +1 -1
  47. package/lib/commonjs/wrap.js +19 -5
  48. package/lib/commonjs/wrap.js.map +1 -1
  49. package/lib/commonjs/wrap.spec.js +74 -0
  50. package/lib/commonjs/wrap.spec.js.map +1 -1
  51. package/lib/module/DefaultResolver.js +6 -2
  52. package/lib/module/DefaultResolver.js.map +1 -1
  53. package/lib/module/DefaultResolver.spec.js +78 -3
  54. package/lib/module/DefaultResolver.spec.js.map +1 -1
  55. package/lib/module/checkForUpdate.js +6 -0
  56. package/lib/module/checkForUpdate.js.map +1 -1
  57. package/lib/module/index.js +53 -18
  58. package/lib/module/index.js.map +1 -1
  59. package/lib/module/index.spec.js +117 -1
  60. package/lib/module/index.spec.js.map +1 -1
  61. package/lib/module/native.js +40 -16
  62. package/lib/module/native.js.map +1 -1
  63. package/lib/module/native.spec.js +119 -3
  64. package/lib/module/native.spec.js.map +1 -1
  65. package/lib/module/sdkVersion.js +4 -0
  66. package/lib/module/sdkVersion.js.map +1 -0
  67. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  68. package/lib/module/store.js +72 -5
  69. package/lib/module/store.js.map +1 -1
  70. package/lib/module/store.spec.js +265 -8
  71. package/lib/module/store.spec.js.map +1 -1
  72. package/lib/module/wrap.js +19 -5
  73. package/lib/module/wrap.js.map +1 -1
  74. package/lib/module/wrap.spec.js +74 -0
  75. package/lib/module/wrap.spec.js.map +1 -1
  76. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/checkForUpdate.d.ts +2 -2
  78. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/index.d.ts +36 -8
  80. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/native.d.ts +56 -3
  82. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/sdkVersion.d.ts +2 -0
  84. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +27 -0
  86. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/store.d.ts +5 -0
  88. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/wrap.d.ts +43 -29
  90. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  91. package/lib/typescript/module/DefaultResolver.d.ts.map +1 -1
  92. package/lib/typescript/module/checkForUpdate.d.ts +2 -2
  93. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  94. package/lib/typescript/module/index.d.ts +36 -8
  95. package/lib/typescript/module/index.d.ts.map +1 -1
  96. package/lib/typescript/module/native.d.ts +56 -3
  97. package/lib/typescript/module/native.d.ts.map +1 -1
  98. package/lib/typescript/module/sdkVersion.d.ts +2 -0
  99. package/lib/typescript/module/sdkVersion.d.ts.map +1 -0
  100. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +27 -0
  101. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  102. package/lib/typescript/module/store.d.ts +5 -0
  103. package/lib/typescript/module/store.d.ts.map +1 -1
  104. package/lib/typescript/module/wrap.d.ts +43 -29
  105. package/lib/typescript/module/wrap.d.ts.map +1 -1
  106. package/package.json +17 -9
  107. package/src/DefaultResolver.spec.ts +103 -3
  108. package/src/DefaultResolver.ts +9 -2
  109. package/src/checkForUpdate.ts +9 -2
  110. package/src/index.spec.ts +149 -1
  111. package/src/index.ts +99 -40
  112. package/src/native.spec.ts +139 -1
  113. package/src/native.ts +145 -31
  114. package/src/sdkVersion.ts +1 -0
  115. package/src/specs/NativeHotUpdater.ts +28 -0
  116. package/src/store.spec.ts +324 -9
  117. package/src/store.ts +133 -7
  118. package/src/wrap.spec.ts +91 -0
  119. package/src/wrap.tsx +79 -42
package/src/index.ts CHANGED
@@ -28,17 +28,21 @@ import {
28
28
  import { hotUpdaterStore } from "./store";
29
29
  import type { HotUpdaterResolver } from "./types";
30
30
  import {
31
+ type AutoUpdateOptions,
31
32
  type HotUpdaterInitOptions,
32
33
  type HotUpdaterOptions,
33
34
  init,
34
35
  type InternalInitOptions,
35
36
  type InternalWrapOptions,
37
+ type ManualUpdateOptions,
36
38
  wrap,
37
39
  } from "./wrap";
38
40
 
39
41
  export type {
40
42
  CustomReloadHandler,
41
43
  HotUpdaterEvent,
44
+ HotUpdaterProgressArtifactType,
45
+ HotUpdaterProgressEvent,
42
46
  Manifest,
43
47
  ManifestAsset,
44
48
  NotifyAppReadyResult,
@@ -56,8 +60,10 @@ export {
56
60
  type SignatureVerificationFailure,
57
61
  } from "./types";
58
62
  export type {
63
+ HotUpdaterFallbackComponentProps,
59
64
  HotUpdaterInitOptions,
60
65
  HotUpdaterOptions,
66
+ ManualUpdateOptions,
61
67
  RunUpdateProcessResponse,
62
68
  } from "./wrap";
63
69
 
@@ -79,6 +85,33 @@ const registerGlobalGetBaseURL = () => {
79
85
  // Call registration immediately on module load
80
86
  registerGlobalGetBaseURL();
81
87
 
88
+ type HotUpdaterWrap = {
89
+ (options: AutoUpdateOptions): ReturnType<typeof wrap>;
90
+ /**
91
+ * @deprecated Replace manual `HotUpdater.wrap` with `HotUpdater.init`.
92
+ *
93
+ * ```tsx
94
+ * import { HotUpdater } from "@hot-updater/react-native";
95
+ *
96
+ * HotUpdater.init({
97
+ * baseURL: "<your-update-server-url>",
98
+ * });
99
+ *
100
+ * export default App;
101
+ * ```
102
+ *
103
+ * Then call `HotUpdater.checkForUpdate(...)` from your manual update flow.
104
+ * See https://hot-updater.dev/docs/guides/custom-update
105
+ */
106
+ (options: ManualUpdateOptions): ReturnType<typeof wrap>;
107
+ (options: HotUpdaterOptions): ReturnType<typeof wrap>;
108
+ };
109
+
110
+ const isManualWrapOptions = (
111
+ options: HotUpdaterOptions,
112
+ ): options is ManualUpdateOptions =>
113
+ "updateMode" in options && options.updateMode === "manual";
114
+
82
115
  /**
83
116
  * Creates a HotUpdater client instance with all update management methods.
84
117
  * This function is called once on module initialization to create a singleton instance.
@@ -89,6 +122,7 @@ function createHotUpdaterClient() {
89
122
  resolver: HotUpdaterResolver | null;
90
123
  requestHeaders?: Record<string, string>;
91
124
  requestTimeout?: number;
125
+ onError?: (error: unknown) => void;
92
126
  } = {
93
127
  resolver: null,
94
128
  };
@@ -96,54 +130,67 @@ function createHotUpdaterClient() {
96
130
  const createMissingNetworkConfigError = (apiName: "init" | "wrap") => {
97
131
  if (apiName === "init") {
98
132
  return new Error(
99
- `[HotUpdater] baseURL must be provided.\n\n` +
100
- `Configure HotUpdater before calling update APIs:\n\n` +
133
+ `[HotUpdater] Either baseURL or resolver must be provided.\n\n` +
134
+ `Configure HotUpdater before calling update APIs with the standard baseURL setup:\n\n` +
101
135
  ` HotUpdater.init({\n` +
102
136
  ` baseURL: "<your-update-server-url>",\n` +
103
137
  ` });\n\n` +
104
- `For more information, visit: https://hot-updater.dev/docs/react-native-api/init`,
138
+ `For manual update flows, visit: https://hot-updater.dev/docs/guides/custom-update`,
105
139
  );
106
140
  }
107
141
 
108
142
  const baseURLExample =
109
143
  ` export default HotUpdater.wrap({\n` +
110
144
  ` baseURL: "<your-update-server-url>",\n` +
111
- ` updateStrategy: "appVersion",\n` +
112
- ` updateMode: "auto"\n` +
113
- ` })(App);\n\n`;
114
-
115
- const resolverExample =
116
- ` export default HotUpdater.wrap({\n` +
117
- ` resolver: {\n` +
118
- ` checkUpdate: async (params) => { /* custom logic */ },\n` +
119
- ` notifyAppReady: async (params) => { /* custom logic */ }\n` +
120
- ` },\n` +
121
- ` updateMode: "manual"\n` +
145
+ ` updateStrategy: "appVersion"\n` +
122
146
  ` })(App);\n\n`;
123
147
 
124
148
  return new Error(
125
149
  `[HotUpdater] Either baseURL or resolver must be provided.\n\n` +
126
- `Option 1: Using baseURL (recommended for most cases)\n` +
150
+ `Configure HotUpdater.wrap with the standard baseURL setup:\n\n` +
127
151
  baseURLExample +
128
- `Option 2: Using custom resolver (advanced)\n` +
129
- resolverExample +
130
- `For more information, visit: https://hot-updater.dev/docs/react-native-api/${apiName}`,
152
+ `For manual update flows, use HotUpdater.init() and visit: ` +
153
+ `https://hot-updater.dev/docs/guides/custom-update`,
131
154
  );
132
155
  };
133
156
 
134
157
  const normalizeOptions = (
135
158
  options: HotUpdaterOptions,
136
159
  ): InternalWrapOptions => {
137
- if ("baseURL" in options && options.baseURL) {
138
- const { baseURL, ...rest } = options;
160
+ if (isManualWrapOptions(options)) {
161
+ if ("baseURL" in options && options.baseURL) {
162
+ const { baseURL, ...rest } = options;
163
+ return {
164
+ ...rest,
165
+ resolver: createDefaultResolver(baseURL),
166
+ updateMode: "manual",
167
+ };
168
+ }
169
+
170
+ if ("resolver" in options && options.resolver) {
171
+ return {
172
+ ...options,
173
+ updateMode: "manual",
174
+ };
175
+ }
176
+ }
177
+
178
+ const autoOptions = options as AutoUpdateOptions;
179
+
180
+ if ("baseURL" in autoOptions && autoOptions.baseURL) {
181
+ const { baseURL, ...rest } = autoOptions;
139
182
  return {
140
183
  ...rest,
141
184
  resolver: createDefaultResolver(baseURL),
185
+ updateMode: "auto",
142
186
  };
143
187
  }
144
188
 
145
- if ("resolver" in options && options.resolver) {
146
- return options;
189
+ if ("resolver" in autoOptions && autoOptions.resolver) {
190
+ return {
191
+ ...autoOptions,
192
+ updateMode: "auto",
193
+ };
147
194
  }
148
195
 
149
196
  throw createMissingNetworkConfigError("wrap");
@@ -152,21 +199,23 @@ function createHotUpdaterClient() {
152
199
  const normalizeInitOptions = (
153
200
  options: HotUpdaterInitOptions,
154
201
  ): InternalInitOptions => {
155
- const {
156
- baseURL,
157
- updateMode: _updateMode,
158
- ...rest
159
- } = options as HotUpdaterInitOptions & {
160
- updateMode?: unknown;
161
- };
162
-
163
- if (baseURL) {
202
+ const { updateMode: _updateMode, ...rest } =
203
+ options as HotUpdaterInitOptions & {
204
+ updateMode?: unknown;
205
+ };
206
+
207
+ if ("baseURL" in rest && rest.baseURL) {
208
+ const { baseURL, ...baseURLRest } = rest;
164
209
  return {
165
- ...rest,
210
+ ...baseURLRest,
166
211
  resolver: createDefaultResolver(baseURL),
167
212
  };
168
213
  }
169
214
 
215
+ if ("resolver" in rest && rest.resolver) {
216
+ return rest;
217
+ }
218
+
170
219
  throw createMissingNetworkConfigError("init");
171
220
  };
172
221
 
@@ -177,6 +226,7 @@ function createHotUpdaterClient() {
177
226
  globalConfig.resolver = normalizedOptions.resolver;
178
227
  globalConfig.requestHeaders = options.requestHeaders;
179
228
  globalConfig.requestTimeout = options.requestTimeout;
229
+ globalConfig.onError = options.onError;
180
230
  };
181
231
 
182
232
  const ensureGlobalResolver = (methodName: string) => {
@@ -187,15 +237,13 @@ function createHotUpdaterClient() {
187
237
  `Option 1: With HotUpdater.wrap()\n` +
188
238
  ` export default HotUpdater.wrap({\n` +
189
239
  ` baseURL: "<your-update-server-url>",\n` +
190
- ` updateStrategy: "appVersion",\n` +
191
- ` updateMode: "auto"\n` +
240
+ ` updateStrategy: "appVersion"\n` +
192
241
  ` })(App);\n\n` +
193
242
  `Option 2: With HotUpdater.init() for custom runtimes\n` +
194
243
  ` HotUpdater.init({\n` +
195
244
  ` baseURL: "<your-update-server-url>",\n` +
196
245
  ` });\n\n` +
197
- `For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap ` +
198
- `or https://hot-updater.dev/docs/react-native-api/init`,
246
+ `For manual update flows, visit: https://hot-updater.dev/docs/guides/custom-update`,
199
247
  );
200
248
  }
201
249
  return globalConfig.resolver;
@@ -219,7 +267,6 @@ function createHotUpdaterClient() {
219
267
  * export default HotUpdater.wrap({
220
268
  * baseURL: "<your-update-server-url>",
221
269
  * updateStrategy: "appVersion",
222
- * updateMode: "auto",
223
270
  * requestHeaders: {
224
271
  * "Authorization": "Bearer <your-access-token>",
225
272
  * },
@@ -237,8 +284,8 @@ function createHotUpdaterClient() {
237
284
  * Initializes HotUpdater without wrapping a React component.
238
285
  *
239
286
  * Use this for manual update flows in runtimes where a root component HOC
240
- * is not convenient. It has the same initialization effect as
241
- * `HotUpdater.wrap({ updateMode: "manual" })(App)`.
287
+ * is not convenient. Replace deprecated manual
288
+ * `HotUpdater.wrap({ updateMode: "manual" })(App)` usage with this API.
242
289
  *
243
290
  * @example
244
291
  * ```tsx
@@ -420,6 +467,7 @@ function createHotUpdaterClient() {
420
467
  ...config.requestHeaders,
421
468
  },
422
469
  requestTimeout: config.requestTimeout ?? globalConfig.requestTimeout,
470
+ onError: config.onError ?? globalConfig.onError,
423
471
  };
424
472
 
425
473
  return checkForUpdate(mergedConfig);
@@ -472,8 +520,12 @@ function createHotUpdaterClient() {
472
520
  const ok = await resetChannel();
473
521
  if (ok) {
474
522
  hotUpdaterStore.setState({
523
+ artifactType: null,
524
+ details: null,
525
+ downloadedBytes: undefined,
475
526
  isUpdateDownloaded: false,
476
527
  progress: 0,
528
+ totalBytes: undefined,
477
529
  });
478
530
  }
479
531
  return ok;
@@ -522,4 +574,11 @@ function createHotUpdaterClient() {
522
574
  };
523
575
  }
524
576
 
525
- export const HotUpdater = createHotUpdaterClient();
577
+ type HotUpdaterClient = Omit<
578
+ ReturnType<typeof createHotUpdaterClient>,
579
+ "wrap"
580
+ > & {
581
+ wrap: HotUpdaterWrap;
582
+ };
583
+
584
+ export const HotUpdater: HotUpdaterClient = createHotUpdaterClient();
@@ -167,6 +167,7 @@ describe("notifyAppReady", () => {
167
167
  assets: {
168
168
  "assets/logo.png": {
169
169
  fileHash: "hash-logo",
170
+ signature: "sig-logo",
170
171
  },
171
172
  "index.android.bundle": {
172
173
  fileHash: "hash-bundle",
@@ -181,6 +182,7 @@ describe("notifyAppReady", () => {
181
182
  assets: {
182
183
  "assets/logo.png": {
183
184
  fileHash: "hash-logo",
185
+ signature: "sig-logo",
184
186
  },
185
187
  "index.android.bundle": {
186
188
  fileHash: "hash-bundle",
@@ -337,11 +339,147 @@ describe("notifyAppReady", () => {
337
339
  bundleId: "bundle-123",
338
340
  });
339
341
  expect(getBaseURL()).toBe("file:///bundle-123");
340
- expect(nativeModuleMock.getBundleId).toHaveBeenCalledTimes(2);
342
+ expect(nativeModuleMock.getBundleId).toHaveBeenCalledTimes(3);
341
343
  expect(nativeModuleMock.getManifest).toHaveBeenCalledTimes(2);
342
344
  expect(nativeModuleMock.getBaseURL).toHaveBeenCalledTimes(2);
343
345
  });
344
346
 
347
+ it("reinstalls a session-installed bundle after native resets to built-in", async () => {
348
+ nativeModuleMock.getConstants.mockReturnValue({
349
+ APP_VERSION: null,
350
+ CHANNEL: "production",
351
+ DEFAULT_CHANNEL: "production",
352
+ FINGERPRINT_HASH: null,
353
+ MIN_BUNDLE_ID: "00000000-0000-0000-0000-000000000001",
354
+ });
355
+ nativeModuleMock.getBundleId.mockReturnValue(
356
+ "00000000-0000-0000-0000-000000000002",
357
+ );
358
+ nativeModuleMock.updateBundle.mockResolvedValue(true);
359
+
360
+ const { getBundleId, updateBundle } = await import("./native");
361
+
362
+ await updateBundle({
363
+ bundleId: "00000000-0000-0000-0000-000000000003",
364
+ fileHash: null,
365
+ fileUrl: "https://example.com/bundle.zip",
366
+ status: "UPDATE",
367
+ });
368
+
369
+ nativeModuleMock.getBundleId.mockReturnValue(
370
+ "00000000-0000-0000-0000-000000000003",
371
+ );
372
+ expect(getBundleId()).toBe("00000000-0000-0000-0000-000000000003");
373
+
374
+ nativeModuleMock.getBundleId.mockReturnValue(null);
375
+
376
+ await expect(
377
+ updateBundle({
378
+ bundleId: "00000000-0000-0000-0000-000000000003",
379
+ fileHash: null,
380
+ fileUrl: "https://example.com/bundle.zip",
381
+ status: "UPDATE",
382
+ }),
383
+ ).resolves.toBe(true);
384
+
385
+ expect(nativeModuleMock.updateBundle).toHaveBeenCalledTimes(2);
386
+ });
387
+
388
+ it("skips a session-installed bundle when native still reports it active", async () => {
389
+ nativeModuleMock.getConstants.mockReturnValue({
390
+ APP_VERSION: null,
391
+ CHANNEL: "production",
392
+ DEFAULT_CHANNEL: "production",
393
+ FINGERPRINT_HASH: null,
394
+ MIN_BUNDLE_ID: "00000000-0000-0000-0000-000000000001",
395
+ });
396
+ nativeModuleMock.getBundleId.mockReturnValue(
397
+ "00000000-0000-0000-0000-000000000002",
398
+ );
399
+ nativeModuleMock.updateBundle.mockResolvedValue(true);
400
+
401
+ const { updateBundle } = await import("./native");
402
+
403
+ await updateBundle({
404
+ bundleId: "00000000-0000-0000-0000-000000000003",
405
+ fileHash: null,
406
+ fileUrl: "https://example.com/bundle.zip",
407
+ status: "UPDATE",
408
+ });
409
+
410
+ nativeModuleMock.getBundleId.mockReturnValue(
411
+ "00000000-0000-0000-0000-000000000003",
412
+ );
413
+
414
+ await expect(
415
+ updateBundle({
416
+ bundleId: "00000000-0000-0000-0000-000000000003",
417
+ fileHash: null,
418
+ fileUrl: "https://example.com/bundle.zip",
419
+ status: "UPDATE",
420
+ }),
421
+ ).resolves.toBe(true);
422
+
423
+ expect(nativeModuleMock.updateBundle).toHaveBeenCalledTimes(1);
424
+ });
425
+
426
+ it("forwards manifest artifact parameters to native updateBundle", async () => {
427
+ nativeModuleMock.getBundleId.mockReturnValue("bundle-123");
428
+ nativeModuleMock.updateBundle.mockResolvedValue(true);
429
+
430
+ const { updateBundle } = await import("./native");
431
+
432
+ await updateBundle({
433
+ bundleId: "bundle-789",
434
+ changedAssets: {
435
+ "index.ios.bundle": {
436
+ file: {
437
+ compression: "br",
438
+ url: "https://example.com/files/index.ios.bundle.br",
439
+ },
440
+ fileHash: "hash-next",
441
+ patch: {
442
+ algorithm: "bsdiff",
443
+ baseBundleId: "bundle-123",
444
+ baseFileHash: "hash-prev",
445
+ patchFileHash: "hash-patch",
446
+ patchUrl: "https://example.com/files/index.ios.bundle.bsdiff",
447
+ },
448
+ },
449
+ },
450
+ fileHash: "sig:archive",
451
+ fileUrl: "https://example.com/bundle.zip",
452
+ manifestFileHash: "sig:manifest",
453
+ manifestUrl: "https://example.com/manifest.json",
454
+ status: "UPDATE",
455
+ });
456
+
457
+ expect(nativeModuleMock.updateBundle).toHaveBeenCalledWith({
458
+ bundleId: "bundle-789",
459
+ changedAssets: {
460
+ "index.ios.bundle": {
461
+ file: {
462
+ compression: "br",
463
+ url: "https://example.com/files/index.ios.bundle.br",
464
+ },
465
+ fileHash: "hash-next",
466
+ patch: {
467
+ algorithm: "bsdiff",
468
+ baseBundleId: "bundle-123",
469
+ baseFileHash: "hash-prev",
470
+ patchFileHash: "hash-patch",
471
+ patchUrl: "https://example.com/files/index.ios.bundle.bsdiff",
472
+ },
473
+ },
474
+ },
475
+ channel: undefined,
476
+ fileHash: "sig:archive",
477
+ fileUrl: "https://example.com/bundle.zip",
478
+ manifestFileHash: "sig:manifest",
479
+ manifestUrl: "https://example.com/manifest.json",
480
+ });
481
+ });
482
+
345
483
  it("invalidates cached bundle getters after resetChannel succeeds", async () => {
346
484
  nativeModuleMock.getConstants.mockReturnValue({
347
485
  APP_VERSION: null,
package/src/native.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ChangedAsset,
2
3
  INVALID_COHORT_ERROR_MESSAGE,
3
4
  isValidCohort,
4
5
  normalizeCohortValue,
@@ -24,6 +25,7 @@ const normalizeAndValidateCohort = (cohort: string): string => {
24
25
 
25
26
  export interface ManifestAsset {
26
27
  fileHash: string;
28
+ signature?: string;
27
29
  }
28
30
 
29
31
  export interface Manifest {
@@ -186,11 +188,38 @@ const cloneManifest = (manifest: Manifest): Manifest => ({
186
188
  assets: Object.fromEntries(
187
189
  Object.entries(manifest.assets).map(([key, asset]) => [
188
190
  key,
189
- { fileHash: asset.fileHash },
191
+ {
192
+ fileHash: asset.fileHash,
193
+ ...(asset.signature ? { signature: asset.signature } : {}),
194
+ },
190
195
  ]),
191
196
  ),
192
197
  });
193
198
 
199
+ const getNativeBundleId = (): string | null => {
200
+ const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
201
+ getBundleId?: () => string | null;
202
+ };
203
+
204
+ if (typeof nativeModule.getBundleId !== "function") {
205
+ throw new Error(
206
+ "[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.",
207
+ );
208
+ }
209
+
210
+ return nativeModule.getBundleId();
211
+ };
212
+
213
+ const resolveBundleId = (bundleId: string | null): string => {
214
+ return !bundleId || bundleId === NIL_UUID ? getMinBundleId() : bundleId;
215
+ };
216
+
217
+ const getFreshBundleId = (): string => {
218
+ const resolvedBundleId = resolveBundleId(getNativeBundleId());
219
+ sessionState.cacheBundleId(resolvedBundleId);
220
+ return resolvedBundleId;
221
+ };
222
+
194
223
  const getReloadProcess = (): (() => Promise<void>) | null => {
195
224
  const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
196
225
  reloadProcess?: () => Promise<void>;
@@ -201,10 +230,73 @@ const getReloadProcess = (): (() => Promise<void>) | null => {
201
230
  : null;
202
231
  };
203
232
 
233
+ export type HotUpdaterProgressArtifactType = "archive" | "diff";
234
+
235
+ export type HotUpdaterDiffFileStatus =
236
+ | "pending"
237
+ | "downloading"
238
+ | "downloaded"
239
+ | "failed";
240
+
241
+ export interface HotUpdaterDiffFileSnapshot {
242
+ /**
243
+ * Manifest asset path for this progress entry.
244
+ *
245
+ * This is the stable identity of the asset in the installed bundle. Use this
246
+ * when matching progress back to the manifest or to the final file location.
247
+ */
248
+ path: string;
249
+ /**
250
+ * Artifact path currently being transferred for this progress entry.
251
+ *
252
+ * Usually this is the same as `path`. It can differ when the updater
253
+ * downloads an intermediate artifact that will be applied to produce the
254
+ * final asset.
255
+ */
256
+ downloadPath: string;
257
+ /**
258
+ * Current download state for this asset within the manifest diff update.
259
+ */
260
+ status: HotUpdaterDiffFileStatus;
261
+ /**
262
+ * Download progress for this file, normalized from 0 to 1.
263
+ */
264
+ progress: number;
265
+ /**
266
+ * Stable display order among diff files.
267
+ */
268
+ order: number;
269
+ /**
270
+ * Bytes downloaded for `downloadPath`, when the native downloader reports it.
271
+ */
272
+ downloadedBytes?: number;
273
+ /**
274
+ * Total expected bytes for `downloadPath`, when known.
275
+ */
276
+ totalBytes?: number;
277
+ }
278
+
279
+ export interface HotUpdaterDiffProgressDetails {
280
+ totalFilesCount: number;
281
+ completedFilesCount: number;
282
+ files: HotUpdaterDiffFileSnapshot[];
283
+ }
284
+
285
+ export type HotUpdaterProgressEvent =
286
+ | {
287
+ progress: number;
288
+ artifactType: "archive";
289
+ downloadedBytes?: number;
290
+ totalBytes?: number;
291
+ }
292
+ | {
293
+ progress: number;
294
+ artifactType: "diff";
295
+ details: HotUpdaterDiffProgressDetails;
296
+ };
297
+
204
298
  export type HotUpdaterEvent = {
205
- onProgress: {
206
- progress: number;
207
- };
299
+ onProgress: HotUpdaterProgressEvent;
208
300
  };
209
301
 
210
302
  const eventEmitter = new NativeEventEmitter(HotUpdaterNative);
@@ -252,8 +344,21 @@ export async function updateBundle(
252
344
  const status =
253
345
  typeof paramsOrBundleId === "string" ? "UPDATE" : paramsOrBundleId.status;
254
346
 
255
- // If we have already installed this bundle in this session, skip re-download.
256
- if (status === "UPDATE" && sessionState.hasInstalledBundle(updateBundleId)) {
347
+ const targetFileUrl =
348
+ typeof paramsOrBundleId === "string"
349
+ ? (fileUrl ?? null)
350
+ : paramsOrBundleId.fileUrl;
351
+
352
+ const currentBundleId = status === "UPDATE" ? getFreshBundleId() : undefined;
353
+
354
+ // If native is still on the same bundle we installed in this session,
355
+ // skip re-download. Native state can move back to the built-in bundle after
356
+ // rollback/reset, so check a fresh native bundle id before using this guard.
357
+ if (
358
+ status === "UPDATE" &&
359
+ sessionState.hasInstalledBundle(updateBundleId) &&
360
+ currentBundleId === updateBundleId
361
+ ) {
257
362
  return true;
258
363
  }
259
364
 
@@ -265,7 +370,8 @@ export async function updateBundle(
265
370
  if (
266
371
  !shouldSkipCurrentBundleIdCheck &&
267
372
  status === "UPDATE" &&
268
- updateBundleId.localeCompare(getBundleId()) <= 0
373
+ currentBundleId !== undefined &&
374
+ updateBundleId.localeCompare(currentBundleId) <= 0
269
375
  ) {
270
376
  throw new Error(
271
377
  "Update bundle id is not newer than the current bundle id. Preventing infinite update loop.",
@@ -276,11 +382,6 @@ export async function updateBundle(
276
382
  const existing = sessionState.getInflightUpdate(updateBundleId);
277
383
  if (existing) return existing;
278
384
 
279
- const targetFileUrl =
280
- typeof paramsOrBundleId === "string"
281
- ? (fileUrl ?? null)
282
- : paramsOrBundleId.fileUrl;
283
-
284
385
  const targetFileHash =
285
386
  typeof paramsOrBundleId === "string"
286
387
  ? undefined
@@ -288,14 +389,30 @@ export async function updateBundle(
288
389
 
289
390
  const targetChannel =
290
391
  typeof paramsOrBundleId === "string" ? undefined : paramsOrBundleId.channel;
392
+ const targetManifestUrl =
393
+ typeof paramsOrBundleId === "string"
394
+ ? undefined
395
+ : paramsOrBundleId.manifestUrl;
396
+ const targetManifestFileHash =
397
+ typeof paramsOrBundleId === "string"
398
+ ? undefined
399
+ : paramsOrBundleId.manifestFileHash;
400
+ const targetChangedAssets =
401
+ typeof paramsOrBundleId === "string"
402
+ ? undefined
403
+ : paramsOrBundleId.changedAssets;
291
404
 
292
405
  const promise = (async () => {
293
406
  try {
294
407
  const ok = await HotUpdaterNative.updateBundle({
295
408
  bundleId: updateBundleId,
296
409
  channel: targetChannel,
410
+ changedAssets:
411
+ (targetChangedAssets as Record<string, ChangedAsset> | null) ?? null,
297
412
  fileUrl: targetFileUrl,
298
413
  fileHash: targetFileHash ?? null,
414
+ manifestFileHash: targetManifestFileHash ?? null,
415
+ manifestUrl: targetManifestUrl ?? null,
299
416
  });
300
417
  if (ok) {
301
418
  sessionState.markBundleInstalled(updateBundleId, targetChannel);
@@ -424,23 +541,7 @@ export const getBundleId = (): string => {
424
541
  return cachedBundleId;
425
542
  }
426
543
 
427
- const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
428
- getBundleId?: () => string | null;
429
- };
430
-
431
- if (typeof nativeModule.getBundleId !== "function") {
432
- throw new Error(
433
- "[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.",
434
- );
435
- }
436
-
437
- const bundleId = nativeModule.getBundleId();
438
-
439
- const resolvedBundleId =
440
- !bundleId || bundleId === NIL_UUID ? getMinBundleId() : bundleId;
441
-
442
- sessionState.cacheBundleId(resolvedBundleId);
443
- return resolvedBundleId;
544
+ return getFreshBundleId();
444
545
  };
445
546
 
446
547
  /**
@@ -617,12 +718,25 @@ const normalizeManifestAssets = (value: unknown): Manifest["assets"] => {
617
718
  return [];
618
719
  }
619
720
 
620
- const { fileHash } = entry as { fileHash?: unknown };
721
+ const { fileHash, signature } = entry as {
722
+ fileHash?: unknown;
723
+ signature?: unknown;
724
+ };
621
725
  if (typeof fileHash !== "string" || !fileHash.trim()) {
622
726
  return [];
623
727
  }
624
728
 
625
- return [[trimmedKey, { fileHash: fileHash.trim() }] as const];
729
+ return [
730
+ [
731
+ trimmedKey,
732
+ {
733
+ fileHash: fileHash.trim(),
734
+ ...(typeof signature === "string" && signature.trim()
735
+ ? { signature: signature.trim() }
736
+ : {}),
737
+ },
738
+ ] as const,
739
+ ];
626
740
  }),
627
741
  );
628
742
  };
@@ -0,0 +1 @@
1
+ export const HOT_UPDATER_SDK_VERSION = "0.30.12";