@hot-updater/react-native 0.36.7 → 1.0.0-rc.1

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 (239) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +357 -6
  2. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +268 -34
  3. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +75 -9
  4. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +2 -16
  5. package/android/src/main/java/com/hotupdater/ReleaseCatalogCacheService.kt +166 -0
  6. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +1 -2
  7. package/android/src/newarch/HotUpdater.kt +1 -1
  8. package/android/src/newarch/HotUpdaterModule.kt +116 -0
  9. package/android/src/oldarch/HotUpdater.kt +1 -1
  10. package/android/src/oldarch/HotUpdaterModule.kt +128 -0
  11. package/android/src/oldarch/HotUpdaterSpec.kt +38 -0
  12. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +280 -2
  13. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +57 -2
  14. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +118 -0
  15. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +491 -30
  16. package/ios/HotUpdater/Internal/BundleMetadata.swift +229 -6
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +122 -0
  18. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +176 -7
  19. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +213 -0
  20. package/lib/commonjs/catalogCacheNative.js +44 -0
  21. package/lib/commonjs/catalogCacheNative.js.map +1 -0
  22. package/lib/commonjs/catalogCacheNative.spec.js +31 -0
  23. package/lib/commonjs/catalogCacheNative.spec.js.map +1 -0
  24. package/lib/commonjs/checkForUpdate.js +231 -60
  25. package/lib/commonjs/checkForUpdate.js.map +1 -1
  26. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +604 -0
  27. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +1 -0
  28. package/lib/commonjs/error.js +8 -1
  29. package/lib/commonjs/error.js.map +1 -1
  30. package/lib/commonjs/{fetchUpdateInfo.js → fetchJSON.js} +11 -14
  31. package/lib/commonjs/fetchJSON.js.map +1 -0
  32. package/lib/commonjs/httpClient.js +153 -0
  33. package/lib/commonjs/httpClient.js.map +1 -0
  34. package/lib/commonjs/httpClient.spec.js +102 -0
  35. package/lib/commonjs/httpClient.spec.js.map +1 -0
  36. package/lib/commonjs/index.js +47 -70
  37. package/lib/commonjs/index.js.map +1 -1
  38. package/lib/commonjs/index.spec.js +115 -159
  39. package/lib/commonjs/index.spec.js.map +1 -1
  40. package/lib/commonjs/iosInterop.spec.js +18 -0
  41. package/lib/commonjs/iosInterop.spec.js.map +1 -0
  42. package/lib/commonjs/native.js +222 -53
  43. package/lib/commonjs/native.js.map +1 -1
  44. package/lib/commonjs/native.spec.js +226 -12
  45. package/lib/commonjs/native.spec.js.map +1 -1
  46. package/lib/commonjs/notifyAppReadyAnalytics.boundary.spec.js +218 -0
  47. package/lib/commonjs/notifyAppReadyAnalytics.boundary.spec.js.map +1 -0
  48. package/lib/commonjs/notifyAppReadyAnalytics.js +133 -0
  49. package/lib/commonjs/notifyAppReadyAnalytics.js.map +1 -0
  50. package/lib/commonjs/notifyAppReadyAnalytics.spec.js +246 -0
  51. package/lib/commonjs/notifyAppReadyAnalytics.spec.js.map +1 -0
  52. package/lib/commonjs/notifyAppReadyAnalytics.test-utils.js +24 -0
  53. package/lib/commonjs/notifyAppReadyAnalytics.test-utils.js.map +1 -0
  54. package/lib/commonjs/notifyAppReadyAnalytics.transport.spec.js +117 -0
  55. package/lib/commonjs/notifyAppReadyAnalytics.transport.spec.js.map +1 -0
  56. package/lib/commonjs/releaseCatalogCache.js +179 -0
  57. package/lib/commonjs/releaseCatalogCache.js.map +1 -0
  58. package/lib/commonjs/releaseCatalogCache.spec.js +195 -0
  59. package/lib/commonjs/releaseCatalogCache.spec.js.map +1 -0
  60. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +64 -0
  61. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +1 -0
  62. package/lib/commonjs/sdkVersion.js +1 -1
  63. package/lib/commonjs/sdkVersion.js.map +1 -1
  64. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  65. package/lib/commonjs/types.js +0 -12
  66. package/lib/commonjs/types.js.map +1 -1
  67. package/lib/commonjs/wrap.js +7 -82
  68. package/lib/commonjs/wrap.js.map +1 -1
  69. package/lib/commonjs/wrap.spec.js +203 -56
  70. package/lib/commonjs/wrap.spec.js.map +1 -1
  71. package/lib/commonjs/wrap.types.js +6 -0
  72. package/lib/commonjs/wrap.types.js.map +1 -0
  73. package/lib/module/catalogCacheNative.js +36 -0
  74. package/lib/module/catalogCacheNative.js.map +1 -0
  75. package/lib/module/catalogCacheNative.spec.js +31 -0
  76. package/lib/module/catalogCacheNative.spec.js.map +1 -0
  77. package/lib/module/checkForUpdate.js +233 -62
  78. package/lib/module/checkForUpdate.js.map +1 -1
  79. package/lib/module/checkForUpdate.releaseCatalog.spec.js +603 -0
  80. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +1 -0
  81. package/lib/module/error.js +6 -0
  82. package/lib/module/error.js.map +1 -1
  83. package/lib/module/{fetchUpdateInfo.js → fetchJSON.js} +9 -12
  84. package/lib/module/fetchJSON.js.map +1 -0
  85. package/lib/module/httpClient.js +148 -0
  86. package/lib/module/httpClient.js.map +1 -0
  87. package/lib/module/httpClient.spec.js +102 -0
  88. package/lib/module/httpClient.spec.js.map +1 -0
  89. package/lib/module/index.js +48 -65
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/index.spec.js +115 -159
  92. package/lib/module/index.spec.js.map +1 -1
  93. package/lib/module/iosInterop.spec.js +18 -0
  94. package/lib/module/iosInterop.spec.js.map +1 -0
  95. package/lib/module/native.js +206 -49
  96. package/lib/module/native.js.map +1 -1
  97. package/lib/module/native.spec.js +226 -12
  98. package/lib/module/native.spec.js.map +1 -1
  99. package/lib/module/notifyAppReadyAnalytics.boundary.spec.js +217 -0
  100. package/lib/module/notifyAppReadyAnalytics.boundary.spec.js.map +1 -0
  101. package/lib/module/notifyAppReadyAnalytics.js +128 -0
  102. package/lib/module/notifyAppReadyAnalytics.js.map +1 -0
  103. package/lib/module/notifyAppReadyAnalytics.spec.js +245 -0
  104. package/lib/module/notifyAppReadyAnalytics.spec.js.map +1 -0
  105. package/lib/module/notifyAppReadyAnalytics.test-utils.js +18 -0
  106. package/lib/module/notifyAppReadyAnalytics.test-utils.js.map +1 -0
  107. package/lib/module/notifyAppReadyAnalytics.transport.spec.js +116 -0
  108. package/lib/module/notifyAppReadyAnalytics.transport.spec.js.map +1 -0
  109. package/lib/module/releaseCatalogCache.js +171 -0
  110. package/lib/module/releaseCatalogCache.js.map +1 -0
  111. package/lib/module/releaseCatalogCache.spec.js +195 -0
  112. package/lib/module/releaseCatalogCache.spec.js.map +1 -0
  113. package/lib/module/releaseCatalogCacheNativeContract.spec.js +64 -0
  114. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +1 -0
  115. package/lib/module/sdkVersion.js +1 -1
  116. package/lib/module/sdkVersion.js.map +1 -1
  117. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  118. package/lib/module/types.js +0 -12
  119. package/lib/module/types.js.map +1 -1
  120. package/lib/module/wrap.js +6 -81
  121. package/lib/module/wrap.js.map +1 -1
  122. package/lib/module/wrap.spec.js +203 -56
  123. package/lib/module/wrap.spec.js.map +1 -1
  124. package/lib/module/wrap.types.js +4 -0
  125. package/lib/module/wrap.types.js.map +1 -0
  126. package/lib/typescript/commonjs/catalogCacheNative.d.ts +4 -0
  127. package/lib/typescript/commonjs/catalogCacheNative.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/checkForUpdate.d.ts +14 -4
  129. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/error.d.ts +3 -0
  131. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/fetchJSON.d.ts +6 -0
  133. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/httpClient.d.ts +56 -0
  135. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/index.d.ts +31 -40
  137. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/native.d.ts +84 -17
  139. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/notifyAppReadyAnalytics.d.ts +13 -0
  141. package/lib/typescript/commonjs/notifyAppReadyAnalytics.d.ts.map +1 -0
  142. package/lib/typescript/commonjs/notifyAppReadyAnalytics.test-utils.d.ts +8 -0
  143. package/lib/typescript/commonjs/notifyAppReadyAnalytics.test-utils.d.ts.map +1 -0
  144. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +25 -0
  145. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -0
  146. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  147. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  148. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +40 -5
  149. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  150. package/lib/typescript/commonjs/types.d.ts +0 -118
  151. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  152. package/lib/typescript/commonjs/wrap.d.ts +2 -191
  153. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  154. package/lib/typescript/commonjs/wrap.types.d.ts +65 -0
  155. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -0
  156. package/lib/typescript/module/catalogCacheNative.d.ts +4 -0
  157. package/lib/typescript/module/catalogCacheNative.d.ts.map +1 -0
  158. package/lib/typescript/module/checkForUpdate.d.ts +14 -4
  159. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  160. package/lib/typescript/module/error.d.ts +3 -0
  161. package/lib/typescript/module/error.d.ts.map +1 -1
  162. package/lib/typescript/module/fetchJSON.d.ts +6 -0
  163. package/lib/typescript/module/fetchJSON.d.ts.map +1 -0
  164. package/lib/typescript/module/httpClient.d.ts +56 -0
  165. package/lib/typescript/module/httpClient.d.ts.map +1 -0
  166. package/lib/typescript/module/index.d.ts +31 -40
  167. package/lib/typescript/module/index.d.ts.map +1 -1
  168. package/lib/typescript/module/native.d.ts +84 -17
  169. package/lib/typescript/module/native.d.ts.map +1 -1
  170. package/lib/typescript/module/notifyAppReadyAnalytics.d.ts +13 -0
  171. package/lib/typescript/module/notifyAppReadyAnalytics.d.ts.map +1 -0
  172. package/lib/typescript/module/notifyAppReadyAnalytics.test-utils.d.ts +8 -0
  173. package/lib/typescript/module/notifyAppReadyAnalytics.test-utils.d.ts.map +1 -0
  174. package/lib/typescript/module/releaseCatalogCache.d.ts +25 -0
  175. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -0
  176. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  177. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  178. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +40 -5
  179. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  180. package/lib/typescript/module/types.d.ts +0 -118
  181. package/lib/typescript/module/types.d.ts.map +1 -1
  182. package/lib/typescript/module/wrap.d.ts +2 -191
  183. package/lib/typescript/module/wrap.d.ts.map +1 -1
  184. package/lib/typescript/module/wrap.types.d.ts +65 -0
  185. package/lib/typescript/module/wrap.types.d.ts.map +1 -0
  186. package/package.json +6 -24
  187. package/src/catalogCacheNative.spec.ts +63 -0
  188. package/src/catalogCacheNative.ts +74 -0
  189. package/src/checkForUpdate.releaseCatalog.spec.ts +596 -0
  190. package/src/checkForUpdate.ts +367 -96
  191. package/src/error.ts +7 -0
  192. package/src/{fetchUpdateInfo.ts → fetchJSON.ts} +8 -15
  193. package/src/httpClient.spec.ts +132 -0
  194. package/src/httpClient.ts +261 -0
  195. package/src/index.spec.ts +108 -201
  196. package/src/index.ts +68 -91
  197. package/src/iosInterop.spec.ts +30 -0
  198. package/src/native.spec.ts +226 -10
  199. package/src/native.ts +385 -81
  200. package/src/notifyAppReadyAnalytics.boundary.spec.ts +234 -0
  201. package/src/notifyAppReadyAnalytics.spec.ts +243 -0
  202. package/src/notifyAppReadyAnalytics.test-utils.ts +31 -0
  203. package/src/notifyAppReadyAnalytics.transport.spec.ts +130 -0
  204. package/src/notifyAppReadyAnalytics.ts +194 -0
  205. package/src/releaseCatalogCache.spec.ts +282 -0
  206. package/src/releaseCatalogCache.ts +341 -0
  207. package/src/releaseCatalogCacheNativeContract.spec.ts +80 -0
  208. package/src/sdkVersion.ts +1 -1
  209. package/src/specs/NativeHotUpdater.ts +51 -5
  210. package/src/types.ts +0 -140
  211. package/src/wrap.spec.ts +251 -56
  212. package/src/wrap.tsx +23 -318
  213. package/src/wrap.types.ts +78 -0
  214. package/android/src/main/java/com/hotupdater/StringResourceUtils.kt +0 -45
  215. package/app.plugin.js +0 -1
  216. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +0 -7
  217. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +0 -4
  218. package/lib/commonjs/DefaultResolver.js +0 -50
  219. package/lib/commonjs/DefaultResolver.js.map +0 -1
  220. package/lib/commonjs/DefaultResolver.spec.js +0 -144
  221. package/lib/commonjs/DefaultResolver.spec.js.map +0 -1
  222. package/lib/commonjs/fetchUpdateInfo.js.map +0 -1
  223. package/lib/module/DefaultResolver.js +0 -46
  224. package/lib/module/DefaultResolver.js.map +0 -1
  225. package/lib/module/DefaultResolver.spec.js +0 -144
  226. package/lib/module/DefaultResolver.spec.js.map +0 -1
  227. package/lib/module/fetchUpdateInfo.js.map +0 -1
  228. package/lib/typescript/commonjs/DefaultResolver.d.ts +0 -11
  229. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +0 -1
  230. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts +0 -7
  231. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts.map +0 -1
  232. package/lib/typescript/module/DefaultResolver.d.ts +0 -11
  233. package/lib/typescript/module/DefaultResolver.d.ts.map +0 -1
  234. package/lib/typescript/module/fetchUpdateInfo.d.ts +0 -7
  235. package/lib/typescript/module/fetchUpdateInfo.d.ts.map +0 -1
  236. package/plugin/build/transformers.js +0 -314
  237. package/plugin/build/withHotUpdater.js +0 -228
  238. package/src/DefaultResolver.spec.ts +0 -193
  239. package/src/DefaultResolver.ts +0 -63
@@ -0,0 +1,194 @@
1
+ import { Platform } from "react-native";
2
+
3
+ import type { HotUpdaterError } from "./error";
4
+ import type { AnalyticsEventParams, HotUpdaterHttpClient } from "./httpClient";
5
+ import {
6
+ getAppVersion,
7
+ getBundleId,
8
+ getChannel,
9
+ getCohort,
10
+ getFingerprintHash,
11
+ getInstallId,
12
+ getPersistedUserIdentity,
13
+ type NotifyAppReadyAnalyticsEvent,
14
+ type NotifyAppReadyResult,
15
+ readNotifyAppReady,
16
+ } from "./native";
17
+
18
+ export type NotifyAppReadyOptions = {
19
+ analytics?: boolean;
20
+ client: HotUpdaterHttpClient;
21
+ requestHeaders?: Record<string, string>;
22
+ requestTimeout?: number;
23
+ onNotifyAppReady?: (result: NotifyAppReadyResult) => void;
24
+ onError?: (error: HotUpdaterError | Error | unknown) => void;
25
+ };
26
+
27
+ type RequestAnimationFrame = (callback: (timestamp: number) => void) => number;
28
+
29
+ let didAttemptAutomaticAnalytics = false;
30
+
31
+ const waitForNextFrame = () =>
32
+ new Promise<void>((resolve) => {
33
+ const requestAnimationFrame = (
34
+ globalThis as typeof globalThis & {
35
+ requestAnimationFrame?: RequestAnimationFrame;
36
+ }
37
+ )?.requestAnimationFrame;
38
+
39
+ if (requestAnimationFrame) {
40
+ requestAnimationFrame(() => resolve());
41
+ return;
42
+ }
43
+
44
+ void Promise.resolve().then(resolve);
45
+ });
46
+
47
+ const assertNever = (value: never): never => {
48
+ throw new Error(`[HotUpdater] Unexpected notifyAppReady status: ${value}`);
49
+ };
50
+
51
+ const buildNotifyAppReadyAnalyticsParams = (
52
+ nativeResult: NotifyAppReadyResult,
53
+ analyticsEvent: NotifyAppReadyAnalyticsEvent | null,
54
+ options: Pick<NotifyAppReadyOptions, "requestHeaders" | "requestTimeout">,
55
+ ): AnalyticsEventParams => {
56
+ const appVersion = getAppVersion();
57
+
58
+ if (!appVersion) {
59
+ throw new Error(
60
+ "[HotUpdater] Automatic analytics requires a non-null native app version.",
61
+ );
62
+ }
63
+
64
+ const { userId, username } = getPersistedUserIdentity();
65
+ const installId = getInstallId();
66
+
67
+ if (!installId) {
68
+ throw new Error(
69
+ "[HotUpdater] Automatic analytics requires a non-null native install id.",
70
+ );
71
+ }
72
+
73
+ const platform: "ios" | "android" =
74
+ Platform.OS === "android" ? "android" : "ios";
75
+
76
+ const commonParams = {
77
+ appVersion,
78
+ channel: getChannel(),
79
+ cohort: getCohort(),
80
+ fingerprintHash: getFingerprintHash(),
81
+ fromReleaseId: analyticsEvent?.fromReleaseId ?? null,
82
+ installId,
83
+ platform,
84
+ requestHeaders: options.requestHeaders,
85
+ requestTimeout: options.requestTimeout,
86
+ toReleaseId: analyticsEvent?.toReleaseId ?? null,
87
+ ...(userId != null ? { userId } : {}),
88
+ ...(username != null ? { username } : {}),
89
+ };
90
+
91
+ switch (nativeResult.status) {
92
+ case "UNCHANGED": {
93
+ const bundleId = getBundleId();
94
+
95
+ if (!bundleId) {
96
+ throw new Error(
97
+ "[HotUpdater] Automatic analytics requires a non-null current bundle id.",
98
+ );
99
+ }
100
+
101
+ return {
102
+ ...commonParams,
103
+ fromBundleId: null,
104
+ toBundleId: bundleId,
105
+ type: "UNCHANGED",
106
+ updateStrategy: null,
107
+ };
108
+ }
109
+ case "UPDATE_APPLIED":
110
+ case "RECOVERED":
111
+ if (!analyticsEvent) {
112
+ throw new Error(
113
+ "[HotUpdater] Native launch report is missing persisted metadata required for automatic analytics.",
114
+ );
115
+ }
116
+
117
+ switch (analyticsEvent.type) {
118
+ case "UPDATE_APPLIED":
119
+ return {
120
+ ...commonParams,
121
+ fromBundleId: analyticsEvent.fromBundleId,
122
+ toBundleId: analyticsEvent.toBundleId,
123
+ type: "UPDATE_APPLIED",
124
+ updateStrategy: analyticsEvent.updateStrategy,
125
+ };
126
+ case "RECOVERED":
127
+ return {
128
+ ...commonParams,
129
+ fromBundleId: analyticsEvent.fromBundleId,
130
+ toBundleId: analyticsEvent.toBundleId,
131
+ type: "RECOVERED",
132
+ updateStrategy: analyticsEvent.updateStrategy,
133
+ };
134
+ default:
135
+ return assertNever(analyticsEvent.type);
136
+ }
137
+ default:
138
+ return assertNever(nativeResult);
139
+ }
140
+ };
141
+
142
+ const maybeSendAutomaticAnalytics = async (
143
+ options: NotifyAppReadyOptions,
144
+ nativeResult: NotifyAppReadyResult,
145
+ analyticsEvent: NotifyAppReadyAnalyticsEvent | null,
146
+ ): Promise<void> => {
147
+ if (!options.analytics || didAttemptAutomaticAnalytics) {
148
+ return;
149
+ }
150
+
151
+ didAttemptAutomaticAnalytics = true;
152
+
153
+ if (nativeResult.status !== "UNCHANGED" && !analyticsEvent) {
154
+ throw new Error(
155
+ "[HotUpdater] Native launch report is missing persisted metadata required for automatic analytics.",
156
+ );
157
+ }
158
+
159
+ const session = await options.client.createSession();
160
+ await session.sendAnalyticsEvent(
161
+ buildNotifyAppReadyAnalyticsParams(nativeResult, analyticsEvent, options),
162
+ );
163
+ };
164
+
165
+ export const handleNotifyAppReady = async (
166
+ options: NotifyAppReadyOptions,
167
+ ): Promise<void> => {
168
+ try {
169
+ let nativeReadResult: ReturnType<typeof readNotifyAppReady>;
170
+ do {
171
+ await waitForNextFrame();
172
+ nativeReadResult = readNotifyAppReady();
173
+ } while (nativeReadResult.pending);
174
+
175
+ const { analyticsEvent, result: nativeResult } = nativeReadResult;
176
+
177
+ try {
178
+ await maybeSendAutomaticAnalytics(options, nativeResult, analyticsEvent);
179
+ } catch (error) {
180
+ const warning = error instanceof Error ? error : new Error(String(error));
181
+ console.warn(
182
+ "[HotUpdater] Automatic notifyAppReady analytics failed:",
183
+ warning,
184
+ );
185
+ }
186
+
187
+ options.onNotifyAppReady?.(nativeResult);
188
+ } catch (error) {
189
+ const normalizedError =
190
+ error instanceof Error ? error : new Error(String(error));
191
+ options.onError?.(error);
192
+ console.warn("[HotUpdater] Failed to notify app ready:", normalizedError);
193
+ }
194
+ };
@@ -0,0 +1,282 @@
1
+ import {
2
+ createReleaseCatalogScopeKey,
3
+ encodeChannelKey,
4
+ MAX_COMPILED_CATALOG_BYTES,
5
+ type ReleaseCatalog,
6
+ } from "@hot-updater/core";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ import {
10
+ createReleaseCatalogCachePartition,
11
+ fetchReleaseCatalogWithCache,
12
+ MAX_RELEASE_CATALOG_CACHE_ENTRY_BYTES,
13
+ MAX_RELEASE_CATALOG_WIRE_BYTES,
14
+ } from "./releaseCatalogCache";
15
+
16
+ const cache = vi.hoisted(() => new Map<string, string>());
17
+ const nativeMocks = vi.hoisted(() => ({
18
+ read: vi.fn(async (partition: string) => cache.get(partition) ?? null),
19
+ remove: vi.fn(async (partition: string) => {
20
+ cache.delete(partition);
21
+ }),
22
+ write: vi.fn(async (partition: string, payload: string) => {
23
+ cache.set(partition, payload);
24
+ return true;
25
+ }),
26
+ }));
27
+
28
+ vi.mock("./catalogCacheNative", () => ({
29
+ readNativeReleaseCatalogCache: nativeMocks.read,
30
+ removeNativeReleaseCatalogCache: nativeMocks.remove,
31
+ writeNativeReleaseCatalogCache: nativeMocks.write,
32
+ }));
33
+
34
+ const CATALOG_ID = "project-a";
35
+ const CHANNEL_KEY = encodeChannelKey("production");
36
+ const SCOPE_KEY = createReleaseCatalogScopeKey({
37
+ channelKey: CHANNEL_KEY,
38
+ platform: "ios",
39
+ strategy: "APP_VERSION",
40
+ });
41
+ const URL = `https://updates.example.com/release-catalogs/app-version/ios/${CHANNEL_KEY}/1.0.0`;
42
+ const EXPECTED_SCOPE = {
43
+ channelKey: CHANNEL_KEY,
44
+ platform: "ios",
45
+ strategy: "APP_VERSION",
46
+ } as const;
47
+
48
+ const catalog: ReleaseCatalog = {
49
+ catalogId: CATALOG_ID,
50
+ catalogHash: `sha256:${"a".repeat(64)}`,
51
+ fallbackPolicy: "BUILTIN_IF_ACTIVE_INELIGIBLE",
52
+ generation: 1,
53
+ releases: [
54
+ {
55
+ bundleId: "00000000-0000-7000-8000-000000000002",
56
+ kind: "BUNDLE",
57
+ message: null,
58
+ releaseId: "00000000-0000-7000-8000-000000000001",
59
+ rolloutCohortCount: 1000,
60
+ shouldForceUpdate: false,
61
+ targetCohorts: [],
62
+ },
63
+ ],
64
+ schemaVersion: 1,
65
+ scopeKey: SCOPE_KEY,
66
+ };
67
+
68
+ const input = (
69
+ overrides: Partial<Parameters<typeof fetchReleaseCatalogWithCache>[0]> = {},
70
+ ) => ({
71
+ baseURL: "https://updates.example.com",
72
+ expectedScope: EXPECTED_SCOPE,
73
+ requestHeaders: { "X-API-Key": "api-key-a" },
74
+ url: URL,
75
+ ...overrides,
76
+ });
77
+
78
+ const response = (status: number, body?: string, etag?: string) =>
79
+ new Response(body, {
80
+ headers: etag === undefined ? undefined : { ETag: etag },
81
+ status,
82
+ statusText: status === 200 ? "OK" : "Request failed",
83
+ });
84
+
85
+ const requestHeadersAt = (
86
+ fetchMock: ReturnType<typeof vi.fn>,
87
+ index: number,
88
+ ) => {
89
+ const request = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined;
90
+ return new Headers(request?.headers);
91
+ };
92
+
93
+ describe("Release Catalog persistent cache", () => {
94
+ beforeEach(() => {
95
+ cache.clear();
96
+ vi.clearAllMocks();
97
+ });
98
+
99
+ it("persists a validated 200 and reuses it across resolver instances on 304", async () => {
100
+ const fetchMock = vi
101
+ .fn()
102
+ .mockResolvedValueOnce(response(200, JSON.stringify(catalog), '"v1"'))
103
+ .mockResolvedValueOnce(response(304));
104
+ vi.stubGlobal("fetch", fetchMock);
105
+
106
+ await expect(fetchReleaseCatalogWithCache(input())).resolves.toEqual(
107
+ catalog,
108
+ );
109
+ expect(nativeMocks.write).toHaveBeenCalledOnce();
110
+
111
+ const parseSpy = vi.spyOn(JSON, "parse");
112
+ await expect(fetchReleaseCatalogWithCache(input())).resolves.toEqual(
113
+ catalog,
114
+ );
115
+ expect(requestHeadersAt(fetchMock, 1).get("if-none-match")).toBe('"v1"');
116
+ expect(parseSpy).not.toHaveBeenCalled();
117
+ expect(fetchMock).toHaveBeenCalledTimes(2);
118
+ });
119
+
120
+ it("repairs an invalid cache after an unsatisfiable 304 with one unconditional request", async () => {
121
+ const partition = createReleaseCatalogCachePartition(input());
122
+ cache.set(partition, "invalid-envelope");
123
+ const fetchMock = vi
124
+ .fn()
125
+ .mockResolvedValueOnce(response(304))
126
+ .mockResolvedValueOnce(response(200, JSON.stringify(catalog), '"v2"'));
127
+ vi.stubGlobal("fetch", fetchMock);
128
+
129
+ await expect(fetchReleaseCatalogWithCache(input())).resolves.toEqual(
130
+ catalog,
131
+ );
132
+
133
+ expect(nativeMocks.remove).toHaveBeenCalledWith(partition);
134
+ expect(fetchMock).toHaveBeenCalledTimes(2);
135
+ expect(requestHeadersAt(fetchMock, 0).has("if-none-match")).toBe(false);
136
+ expect(requestHeadersAt(fetchMock, 1).has("if-none-match")).toBe(false);
137
+ expect(cache.get(partition)).toContain('"v2"');
138
+ });
139
+
140
+ it("isolates base URL, scope, and case-insensitive API-key partitions", async () => {
141
+ const betaScope = createReleaseCatalogScopeKey({
142
+ channelKey: encodeChannelKey("beta"),
143
+ platform: "ios",
144
+ strategy: "APP_VERSION",
145
+ });
146
+ const fetchMock = vi
147
+ .fn()
148
+ .mockResolvedValueOnce(response(200, JSON.stringify(catalog), '"v1"'))
149
+ .mockResolvedValueOnce(response(304))
150
+ .mockResolvedValueOnce(response(200, JSON.stringify(catalog), '"key-b"'))
151
+ .mockResolvedValueOnce(
152
+ response(
153
+ 200,
154
+ JSON.stringify({ ...catalog, scopeKey: betaScope }),
155
+ '"beta"',
156
+ ),
157
+ );
158
+ vi.stubGlobal("fetch", fetchMock);
159
+
160
+ await fetchReleaseCatalogWithCache(input());
161
+ await fetchReleaseCatalogWithCache(
162
+ input({ requestHeaders: { "x-api-key": "api-key-a" } }),
163
+ );
164
+ await fetchReleaseCatalogWithCache(
165
+ input({ requestHeaders: { "x-api-key": "api-key-b" } }),
166
+ );
167
+ await fetchReleaseCatalogWithCache(
168
+ input({
169
+ baseURL: "https://other.example.com",
170
+ expectedScope: {
171
+ channelKey: encodeChannelKey("beta"),
172
+ platform: "ios",
173
+ strategy: "APP_VERSION",
174
+ },
175
+ url: "https://other.example.com/release-catalogs/app-version/ios/YmV0YQ/1.0.0",
176
+ }),
177
+ );
178
+
179
+ expect(requestHeadersAt(fetchMock, 1).get("if-none-match")).toBe('"v1"');
180
+ expect(requestHeadersAt(fetchMock, 2).has("if-none-match")).toBe(false);
181
+ expect(requestHeadersAt(fetchMock, 3).has("if-none-match")).toBe(false);
182
+ expect(cache.size).toBe(3);
183
+ });
184
+
185
+ it.each([401, 403, 500])(
186
+ "does not use or repair cached data after HTTP %s",
187
+ async (status) => {
188
+ const fetchMock = vi
189
+ .fn()
190
+ .mockResolvedValueOnce(response(200, JSON.stringify(catalog), '"v1"'))
191
+ .mockResolvedValueOnce(response(status));
192
+ vi.stubGlobal("fetch", fetchMock);
193
+ await fetchReleaseCatalogWithCache(input());
194
+
195
+ await expect(fetchReleaseCatalogWithCache(input())).rejects.toThrow(
196
+ "Request failed",
197
+ );
198
+ expect(fetchMock).toHaveBeenCalledTimes(2);
199
+ },
200
+ );
201
+
202
+ it("does not use cached data as a fallback on network failure", async () => {
203
+ const fetchMock = vi
204
+ .fn()
205
+ .mockResolvedValueOnce(response(200, JSON.stringify(catalog), '"v1"'))
206
+ .mockRejectedValueOnce(new Error("offline"));
207
+ vi.stubGlobal("fetch", fetchMock);
208
+ await fetchReleaseCatalogWithCache(input());
209
+
210
+ await expect(fetchReleaseCatalogWithCache(input())).rejects.toThrow(
211
+ "offline",
212
+ );
213
+ expect(fetchMock).toHaveBeenCalledTimes(2);
214
+ });
215
+
216
+ it("rejects and never persists a catalog with the wrong canonical scope", async () => {
217
+ const fetchMock = vi
218
+ .fn()
219
+ .mockResolvedValueOnce(
220
+ response(
221
+ 200,
222
+ JSON.stringify({ ...catalog, scopeKey: "wrong-scope" }),
223
+ '"wrong"',
224
+ ),
225
+ );
226
+ vi.stubGlobal("fetch", fetchMock);
227
+
228
+ await expect(fetchReleaseCatalogWithCache(input())).rejects.toThrow(
229
+ "invalid Release catalog",
230
+ );
231
+ expect(nativeMocks.write).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it("rejects a catalog with no persisted identity", async () => {
235
+ const fetchMock = vi
236
+ .fn()
237
+ .mockResolvedValueOnce(
238
+ response(
239
+ 200,
240
+ JSON.stringify({ ...catalog, catalogId: "" }),
241
+ '"missing-identity"',
242
+ ),
243
+ );
244
+ vi.stubGlobal("fetch", fetchMock);
245
+
246
+ await expect(fetchReleaseCatalogWithCache(input())).rejects.toThrow(
247
+ "invalid Release catalog",
248
+ );
249
+ expect(nativeMocks.write).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("accepts a legal compiled-boundary catalog plus its wire envelope", async () => {
253
+ const template = {
254
+ ...catalog,
255
+ releases: [{ ...catalog.releases[0]!, message: "" }],
256
+ };
257
+ const templateBody = JSON.stringify(template);
258
+ const padding = "x".repeat(
259
+ MAX_COMPILED_CATALOG_BYTES + 1 - templateBody.length,
260
+ );
261
+ const body = JSON.stringify({
262
+ ...template,
263
+ releases: [{ ...template.releases[0]!, message: padding }],
264
+ });
265
+ expect(body.length).toBe(MAX_COMPILED_CATALOG_BYTES + 1);
266
+ expect(body.length).toBeLessThanOrEqual(MAX_RELEASE_CATALOG_WIRE_BYTES);
267
+ const fetchMock = vi
268
+ .fn()
269
+ .mockResolvedValueOnce(response(200, body, '"boundary"'));
270
+ vi.stubGlobal("fetch", fetchMock);
271
+
272
+ await expect(fetchReleaseCatalogWithCache(input())).resolves.toEqual(
273
+ JSON.parse(body),
274
+ );
275
+
276
+ const persisted = nativeMocks.write.mock.calls[0]?.[1];
277
+ expect(persisted).toBeDefined();
278
+ expect(new TextEncoder().encode(persisted).byteLength).toBeLessThanOrEqual(
279
+ MAX_RELEASE_CATALOG_CACHE_ENTRY_BYTES,
280
+ );
281
+ });
282
+ });