@hot-updater/react-native 0.36.7 → 1.0.0-rc.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 (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 +48 -71
  37. package/lib/commonjs/index.js.map +1 -1
  38. package/lib/commonjs/index.spec.js +117 -161
  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 -55
  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 +49 -66
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/index.spec.js +117 -161
  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 -51
  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 +32 -41
  137. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/native.d.ts +83 -18
  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 +32 -41
  167. package/lib/typescript/module/index.d.ts.map +1 -1
  168. package/lib/typescript/module/native.d.ts +83 -18
  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 +368 -97
  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 +110 -203
  196. package/src/index.ts +70 -93
  197. package/src/iosInterop.spec.ts +30 -0
  198. package/src/native.spec.ts +226 -10
  199. package/src/native.ts +385 -83
  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
@@ -1,23 +1,40 @@
1
- import type { AppUpdateAvailableInfo, AppUpdateInfo } from "@hot-updater/core";
1
+ import {
2
+ authorizeReleaseTransition,
3
+ createReleaseSelectionContextHash,
4
+ encodeChannelKey,
5
+ selectDesiredRelease,
6
+ type ArtifactInfo,
7
+ type PersistedSelectionReceipt,
8
+ type ReleaseCatalog,
9
+ } from "@hot-updater/core";
10
+ import { canonicalizeAppVersion } from "@hot-updater/plugin-core";
2
11
  import { Platform } from "react-native";
3
12
 
4
- import { HotUpdaterError } from "./error";
13
+ import { HotUpdaterError, StaleReleaseCatalogError } from "./error";
14
+ import type { HotUpdaterHttpClient, HotUpdaterHttpSession } from "./httpClient";
5
15
  import {
16
+ acceptReleaseCatalog,
17
+ commitReleaseSelection,
18
+ getActiveUpdateState,
6
19
  getAppVersion,
7
20
  getBundleId,
8
21
  getChannel,
9
22
  getCohort,
10
23
  getDefaultChannel,
11
24
  getFingerprintHash,
12
- getMinBundleId,
25
+ getInstallId,
26
+ getMinimumReleaseId,
27
+ getCrashHistory,
28
+ getPersistedUserIdentity,
29
+ isReleaseSelectionCurrent,
13
30
  isChannelSwitched,
14
- resetChannel,
15
31
  updateBundle,
16
32
  } from "./native";
33
+ import {
34
+ hasExpectedReleaseCatalogScope,
35
+ type ExpectedReleaseCatalogScope,
36
+ } from "./releaseCatalogCache";
17
37
  import { hotUpdaterStore } from "./store";
18
- import type { HotUpdaterResolver } from "./types";
19
-
20
- const NIL_UUID = "00000000-0000-0000-0000-000000000000";
21
38
 
22
39
  export interface CheckForUpdateOptions {
23
40
  /**
@@ -43,7 +60,15 @@ export interface CheckForUpdateOptions {
43
60
  requestTimeout?: number;
44
61
  }
45
62
 
46
- export type CheckForUpdateResult = AppUpdateAvailableInfo & {
63
+ export type CheckForUpdateResult = ArtifactInfo & {
64
+ readonly id: string;
65
+ readonly message: string | null;
66
+ readonly rolloutCohortCount: number;
67
+ readonly shouldForceUpdate: boolean;
68
+ readonly status: "ROLLBACK" | "UPDATE";
69
+ readonly targetCohorts: string[];
70
+ readonly releaseId?: string | null;
71
+ readonly transitionKind?: ReleaseTransitionKind;
47
72
  /**
48
73
  * Updates the bundle.
49
74
  * This method is equivalent to `HotUpdater.updateBundle()` but with all required arguments pre-filled.
@@ -51,19 +76,334 @@ export type CheckForUpdateResult = AppUpdateAvailableInfo & {
51
76
  updateBundle: () => Promise<boolean>;
52
77
  };
53
78
 
54
- // Internal type that includes resolver for use within index.ts
79
+ export type ReleaseTransitionKind =
80
+ | "INSTALL"
81
+ | "ADOPT_RELEASE"
82
+ | "USE_EMBEDDED"
83
+ | "USE_BUILTIN";
84
+
55
85
  export interface InternalCheckForUpdateOptions extends CheckForUpdateOptions {
56
- resolver: HotUpdaterResolver;
86
+ analytics?: boolean;
87
+ client: HotUpdaterHttpClient;
57
88
  }
58
89
 
59
- const isResetToBuiltInResponse = (updateInfo: AppUpdateInfo): boolean => {
60
- return (
61
- updateInfo.status === "ROLLBACK" &&
62
- updateInfo.id === NIL_UUID &&
63
- updateInfo.fileUrl === null
64
- );
90
+ const sameReceipt = (
91
+ first: PersistedSelectionReceipt | null,
92
+ second: PersistedSelectionReceipt,
93
+ ): boolean =>
94
+ first !== null &&
95
+ first.kind === second.kind &&
96
+ first.releaseId === second.releaseId &&
97
+ first.bundleId === second.bundleId &&
98
+ first.catalogId === second.catalogId &&
99
+ first.scopeKey === second.scopeKey &&
100
+ first.generation === second.generation &&
101
+ first.catalogHash === second.catalogHash &&
102
+ first.channel === second.channel &&
103
+ first.selectionContextHash === second.selectionContextHash;
104
+
105
+ const validateCatalog = (
106
+ catalog: ReleaseCatalog,
107
+ expectedScope: ExpectedReleaseCatalogScope,
108
+ ) => {
109
+ if (
110
+ catalog.schemaVersion !== 1 ||
111
+ !Number.isSafeInteger(catalog.generation) ||
112
+ catalog.generation < 1 ||
113
+ !/^sha256:[0-9a-f]{64}$/.test(catalog.catalogHash) ||
114
+ !hasExpectedReleaseCatalogScope(catalog, expectedScope)
115
+ ) {
116
+ throw new HotUpdaterError("Received an invalid Release catalog");
117
+ }
118
+ };
119
+
120
+ const resetProgress = () => {
121
+ hotUpdaterStore.setState({
122
+ artifactType: null,
123
+ details: null,
124
+ downloadedBytes: undefined,
125
+ isUpdateDownloaded: false,
126
+ progress: 0,
127
+ totalBytes: undefined,
128
+ });
129
+ };
130
+
131
+ const notifyReleaseAdoption = async (input: {
132
+ readonly active: PersistedSelectionReceipt | null;
133
+ readonly desired: PersistedSelectionReceipt;
134
+ readonly appVersion: string;
135
+ readonly cohort: string;
136
+ readonly fingerprintHash: string | null;
137
+ readonly platform: "ios" | "android";
138
+ readonly session: HotUpdaterHttpSession;
139
+ readonly requestHeaders?: Record<string, string>;
140
+ readonly requestTimeout?: number;
141
+ readonly updateStrategy: "appVersion" | "fingerprint";
142
+ }): Promise<void> => {
143
+ const { userId, username } = getPersistedUserIdentity();
144
+ try {
145
+ await input.session.sendAnalyticsEvent({
146
+ appVersion: input.appVersion,
147
+ channel: input.desired.channel,
148
+ cohort: input.cohort,
149
+ fingerprintHash: input.fingerprintHash,
150
+ fromBundleId: input.desired.bundleId,
151
+ fromReleaseId: input.active?.releaseId ?? null,
152
+ installId: getInstallId(),
153
+ platform: input.platform,
154
+ requestHeaders: input.requestHeaders,
155
+ requestTimeout: input.requestTimeout,
156
+ toBundleId: input.desired.bundleId,
157
+ toReleaseId: input.desired.releaseId,
158
+ type: "RELEASE_ADOPTED",
159
+ updateStrategy: input.updateStrategy,
160
+ ...(userId === undefined ? {} : { userId }),
161
+ ...(username === undefined ? {} : { username }),
162
+ });
163
+ } catch (error) {
164
+ console.warn("[HotUpdater] Release adoption analytics failed:", error);
165
+ }
65
166
  };
66
167
 
168
+ async function checkForReleaseCatalogUpdate(input: {
169
+ readonly options: InternalCheckForUpdateOptions;
170
+ readonly platform: "ios" | "android";
171
+ readonly currentAppVersion: string;
172
+ readonly currentBundleId: string;
173
+ readonly minimumReleaseId: string;
174
+ readonly defaultChannel: string;
175
+ readonly currentChannel: string;
176
+ readonly targetChannel: string;
177
+ readonly explicitChannel: string | undefined;
178
+ readonly isSwitched: boolean;
179
+ readonly cohort: string;
180
+ readonly fingerprintHash: string | null;
181
+ }): Promise<CheckForUpdateResult | null> {
182
+ const { options } = input;
183
+ const session = await options.client.createSession();
184
+ const channelKey = encodeChannelKey(input.targetChannel);
185
+ const strategy =
186
+ options.updateStrategy === "appVersion" ? "APP_VERSION" : "FINGERPRINT";
187
+ const canonicalAppVersion = canonicalizeAppVersion(input.currentAppVersion);
188
+ if (strategy === "APP_VERSION" && canonicalAppVersion === null) {
189
+ throw new HotUpdaterError("Failed to canonicalize app version");
190
+ }
191
+ if (strategy === "FINGERPRINT" && !input.fingerprintHash) {
192
+ throw new HotUpdaterError("Fingerprint hash is required");
193
+ }
194
+ const expectedScope: ExpectedReleaseCatalogScope =
195
+ strategy === "APP_VERSION"
196
+ ? {
197
+ channelKey,
198
+ platform: input.platform,
199
+ strategy,
200
+ }
201
+ : {
202
+ channelKey,
203
+ fingerprintHash: input.fingerprintHash!,
204
+ platform: input.platform,
205
+ strategy,
206
+ };
207
+ const catalog = await session.fetchReleaseCatalog({
208
+ appVersion: canonicalAppVersion ?? input.currentAppVersion,
209
+ channel: input.targetChannel,
210
+ fingerprintHash: input.fingerprintHash,
211
+ platform: input.platform,
212
+ requestHeaders: options.requestHeaders,
213
+ requestTimeout: options.requestTimeout,
214
+ updateStrategy: options.updateStrategy,
215
+ });
216
+ validateCatalog(catalog, expectedScope);
217
+ const catalogId = catalog.catalogId;
218
+ const scopeKey = catalog.scopeKey;
219
+
220
+ const crashedBundleIds = getCrashHistory();
221
+ const active = getActiveUpdateState().activeSelection;
222
+ const explicitScopeSwitch =
223
+ input.explicitChannel !== undefined &&
224
+ input.explicitChannel !== input.currentChannel;
225
+ const activeInTargetScope =
226
+ active?.catalogId === catalogId && active.scopeKey === scopeKey
227
+ ? active
228
+ : null;
229
+ const hasAuthenticatedActive =
230
+ active !== null &&
231
+ active.catalogId !== null &&
232
+ active.scopeKey !== null &&
233
+ active.generation !== null;
234
+ if (
235
+ hasAuthenticatedActive &&
236
+ activeInTargetScope === null &&
237
+ !explicitScopeSwitch
238
+ ) {
239
+ throw new HotUpdaterError("Release transition rejected: UNSOLICITED_SCOPE");
240
+ }
241
+ const selectorCurrentBundleId =
242
+ activeInTargetScope !== null ||
243
+ (!hasAuthenticatedActive && !explicitScopeSwitch)
244
+ ? input.currentBundleId
245
+ : input.minimumReleaseId;
246
+ const selectionContextHash = createReleaseSelectionContextHash({
247
+ activeBundleId: selectorCurrentBundleId,
248
+ activeReleaseId: activeInTargetScope?.releaseId ?? null,
249
+ cohort: input.cohort,
250
+ crashedBundleIds,
251
+ minimumReleaseId: input.minimumReleaseId,
252
+ strategy,
253
+ strategyValue:
254
+ strategy === "APP_VERSION"
255
+ ? canonicalAppVersion!
256
+ : input.fingerprintHash!,
257
+ });
258
+ if (
259
+ !acceptReleaseCatalog({
260
+ catalogId,
261
+ catalogHash: catalog.catalogHash,
262
+ channel: input.targetChannel,
263
+ generation: catalog.generation,
264
+ selectionContextHash,
265
+ scopeKey,
266
+ })
267
+ ) {
268
+ throw new HotUpdaterError(
269
+ "Rejected a stale or inconsistent Release catalog",
270
+ );
271
+ }
272
+ const desired = selectDesiredRelease(catalog, {
273
+ activeReleaseId: activeInTargetScope?.releaseId ?? null,
274
+ builtInBundleId: input.minimumReleaseId,
275
+ cohort: input.cohort,
276
+ crashedBundleIds,
277
+ currentBundleId: selectorCurrentBundleId,
278
+ minimumReleaseId: input.minimumReleaseId,
279
+ });
280
+ if (desired === null) return null;
281
+
282
+ if (
283
+ desired.kind === "BUILTIN" &&
284
+ input.currentBundleId === input.minimumReleaseId &&
285
+ (active === null || active.kind === "BUILTIN")
286
+ ) {
287
+ return null;
288
+ }
289
+
290
+ const receipt: PersistedSelectionReceipt = {
291
+ catalogId,
292
+ bundleId: desired.bundleId,
293
+ catalogHash: catalog.catalogHash,
294
+ channel: input.targetChannel,
295
+ generation: catalog.generation,
296
+ kind: desired.kind,
297
+ releaseId: desired.releaseId,
298
+ scopeKey,
299
+ selectionContextHash,
300
+ };
301
+ if (sameReceipt(active, receipt)) return null;
302
+
303
+ const authorization = authorizeReleaseTransition({
304
+ active,
305
+ desired: receipt,
306
+ explicitScopeSwitch,
307
+ });
308
+ if (!authorization.authorized) {
309
+ if (authorization.reason === "EMPTY_TARGET_SCOPE") return null;
310
+ throw new HotUpdaterError(
311
+ `Release transition rejected: ${authorization.reason}`,
312
+ );
313
+ }
314
+
315
+ const release = desired.release;
316
+ const transitionKind: ReleaseTransitionKind =
317
+ desired.kind === "BUILTIN"
318
+ ? "USE_BUILTIN"
319
+ : desired.kind === "EMBEDDED"
320
+ ? "USE_EMBEDDED"
321
+ : active?.bundleId === desired.bundleId
322
+ ? "ADOPT_RELEASE"
323
+ : "INSTALL";
324
+ const guard = {
325
+ catalogId,
326
+ catalogHash: catalog.catalogHash,
327
+ channel: input.targetChannel,
328
+ generation: catalog.generation,
329
+ scopeKey,
330
+ selectionContextHash,
331
+ };
332
+ const updateBundleForSelection = async (): Promise<boolean> => {
333
+ resetProgress();
334
+ if (!isReleaseSelectionCurrent(guard)) {
335
+ throw new StaleReleaseCatalogError();
336
+ }
337
+
338
+ if (transitionKind !== "INSTALL") {
339
+ const committed = await commitReleaseSelection({
340
+ guard,
341
+ selection: receipt,
342
+ });
343
+ if (
344
+ committed &&
345
+ transitionKind === "ADOPT_RELEASE" &&
346
+ options.analytics
347
+ ) {
348
+ await notifyReleaseAdoption({
349
+ active,
350
+ appVersion: input.currentAppVersion,
351
+ cohort: input.cohort,
352
+ desired: receipt,
353
+ fingerprintHash: input.fingerprintHash,
354
+ platform: input.platform,
355
+ requestHeaders: options.requestHeaders,
356
+ requestTimeout: options.requestTimeout,
357
+ session,
358
+ updateStrategy: options.updateStrategy,
359
+ });
360
+ }
361
+ return committed;
362
+ }
363
+
364
+ const artifact = await session.resolveArtifact({
365
+ currentBundleId: input.currentBundleId,
366
+ requestHeaders: options.requestHeaders,
367
+ requestTimeout: options.requestTimeout,
368
+ targetBundleId: desired.bundleId,
369
+ });
370
+ if (!isReleaseSelectionCurrent(guard)) {
371
+ throw new StaleReleaseCatalogError();
372
+ }
373
+ return updateBundle({
374
+ bundleId: desired.bundleId,
375
+ changedAssets: artifact.changedAssets ?? null,
376
+ channel: input.targetChannel,
377
+ fileHash: artifact.fileHash,
378
+ fileUrl: artifact.fileUrl,
379
+ manifestFileHash: artifact.manifestFileHash ?? null,
380
+ manifestUrl: artifact.manifestUrl ?? null,
381
+ selection: receipt,
382
+ shouldSkipCurrentBundleIdCheck: true,
383
+ status: desired.status,
384
+ });
385
+ };
386
+
387
+ return {
388
+ fileHash: null,
389
+ fileUrl: null,
390
+ id: desired.bundleId,
391
+ message: release?.message ?? null,
392
+ releaseId: desired.releaseId,
393
+ rolloutCohortCount: release?.rolloutCohortCount ?? 1000,
394
+ shouldForceUpdate:
395
+ transitionKind === "ADOPT_RELEASE"
396
+ ? false
397
+ : desired.status === "ROLLBACK"
398
+ ? true
399
+ : (release?.shouldForceUpdate ?? false),
400
+ status: desired.status,
401
+ targetCohorts: release?.targetCohorts ? [...release.targetCohorts] : [],
402
+ transitionKind,
403
+ updateBundle: updateBundleForSelection,
404
+ };
405
+ }
406
+
67
407
  export async function checkForUpdate(
68
408
  options: InternalCheckForUpdateOptions,
69
409
  ): Promise<CheckForUpdateResult | null> {
@@ -81,20 +421,12 @@ export async function checkForUpdate(
81
421
  const currentAppVersion = getAppVersion();
82
422
  const platform = Platform.OS as "ios" | "android";
83
423
  const currentBundleId = getBundleId();
84
- const minBundleId = getMinBundleId();
424
+ const minimumReleaseId = getMinimumReleaseId();
85
425
  const defaultChannel = getDefaultChannel();
86
426
  const isSwitched = isChannelSwitched();
87
427
  const currentChannel = isSwitched ? getChannel() : defaultChannel;
88
428
  const explicitChannel = options.channel || undefined;
89
429
  const targetChannel = explicitChannel || currentChannel;
90
- const isFirstRuntimeChannelSwitchAttempt =
91
- !isSwitched &&
92
- explicitChannel !== undefined &&
93
- explicitChannel !== defaultChannel;
94
- const requestBundleId = isFirstRuntimeChannelSwitchAttempt
95
- ? minBundleId
96
- : currentBundleId;
97
-
98
430
  const cohort = getCohort();
99
431
 
100
432
  if (!currentAppVersion) {
@@ -112,84 +444,23 @@ export async function checkForUpdate(
112
444
 
113
445
  const fingerprintHash = getFingerprintHash();
114
446
 
115
- if (!options.resolver?.checkUpdate) {
116
- options.onError?.(
117
- new HotUpdaterError("Resolver is required but not configured"),
118
- );
119
- return null;
120
- }
121
-
122
- let updateInfo: AppUpdateInfo | null = null;
123
-
124
447
  try {
125
- updateInfo = await options.resolver.checkUpdate({
126
- platform,
127
- appVersion: currentAppVersion,
128
- bundleId: requestBundleId,
129
- minBundleId,
448
+ return await checkForReleaseCatalogUpdate({
130
449
  cohort,
131
- channel: targetChannel,
132
- updateStrategy: options.updateStrategy,
450
+ currentAppVersion,
451
+ currentBundleId,
452
+ currentChannel,
453
+ defaultChannel,
454
+ explicitChannel,
133
455
  fingerprintHash,
134
- requestHeaders: options.requestHeaders,
135
- requestTimeout: options.requestTimeout,
456
+ isSwitched,
457
+ minimumReleaseId,
458
+ options,
459
+ platform,
460
+ targetChannel,
136
461
  });
137
462
  } catch (error) {
138
463
  options.onError?.(error as Error);
139
464
  return null;
140
465
  }
141
-
142
- if (!updateInfo) {
143
- return null;
144
- }
145
-
146
- if (updateInfo.status === "UP_TO_DATE") {
147
- return null;
148
- }
149
-
150
- if (
151
- explicitChannel &&
152
- explicitChannel !== defaultChannel &&
153
- !isSwitched &&
154
- updateInfo.status === "ROLLBACK"
155
- ) {
156
- return null;
157
- }
158
-
159
- return {
160
- ...updateInfo,
161
- updateBundle: async () => {
162
- if (
163
- explicitChannel &&
164
- isSwitched &&
165
- isResetToBuiltInResponse(updateInfo)
166
- ) {
167
- return resetChannel();
168
- }
169
-
170
- const runtimeChannel =
171
- updateInfo.fileUrl !== null ? targetChannel : undefined;
172
-
173
- hotUpdaterStore.setState({
174
- artifactType: null,
175
- details: null,
176
- downloadedBytes: undefined,
177
- isUpdateDownloaded: false,
178
- progress: 0,
179
- totalBytes: undefined,
180
- });
181
-
182
- return updateBundle({
183
- bundleId: updateInfo.id,
184
- channel: runtimeChannel,
185
- changedAssets: updateInfo.changedAssets ?? null,
186
- fileUrl: updateInfo.fileUrl,
187
- fileHash: updateInfo.fileHash,
188
- manifestFileHash: updateInfo.manifestFileHash ?? null,
189
- manifestUrl: updateInfo.manifestUrl ?? null,
190
- status: updateInfo.status,
191
- shouldSkipCurrentBundleIdCheck: isFirstRuntimeChannelSwitchAttempt,
192
- });
193
- },
194
- };
195
466
  }
package/src/error.ts CHANGED
@@ -157,3 +157,10 @@ export class HotUpdaterError extends Error {
157
157
  this.name = "HotUpdaterError";
158
158
  }
159
159
  }
160
+
161
+ export class StaleReleaseCatalogError extends HotUpdaterError {
162
+ constructor() {
163
+ super("Release catalog selection became stale before it was committed");
164
+ this.name = "StaleReleaseCatalogError";
165
+ }
166
+ }
@@ -1,6 +1,4 @@
1
- import type { AppUpdateInfo } from "@hot-updater/core";
2
-
3
- export const fetchUpdateInfo = async ({
1
+ export const fetchJSON = async <T>({
4
2
  url,
5
3
  requestHeaders,
6
4
  requestTimeout = 5000,
@@ -8,27 +6,22 @@ export const fetchUpdateInfo = async ({
8
6
  url: string;
9
7
  requestHeaders?: Record<string, string>;
10
8
  requestTimeout?: number;
11
- }): Promise<AppUpdateInfo | null> => {
9
+ }): Promise<T> => {
12
10
  const controller = new AbortController();
13
- const timeoutId = setTimeout(() => {
14
- controller.abort();
15
- }, requestTimeout);
11
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
16
12
 
17
13
  try {
18
- const headers = {
19
- "Content-Type": "application/json",
20
- ...requestHeaders,
21
- };
22
-
23
14
  const response = await fetch(url, {
15
+ headers: {
16
+ Accept: "application/json",
17
+ ...requestHeaders,
18
+ },
24
19
  signal: controller.signal,
25
- headers,
26
20
  });
27
-
28
21
  if (response.status !== 200) {
29
22
  throw new Error(response.statusText);
30
23
  }
31
- return response.json();
24
+ return (await response.json()) as T;
32
25
  } catch (error: unknown) {
33
26
  if (error instanceof Error && error.name === "AbortError") {
34
27
  throw new Error("Request timed out");
@@ -0,0 +1,132 @@
1
+ import type { ArtifactInfo, ReleaseCatalog } from "@hot-updater/core";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { createHttpClient } from "./httpClient";
5
+
6
+ const mocks = vi.hoisted(() => {
7
+ Reflect.set(globalThis, "HotUpdater", {
8
+ SDK_VERSION: "test-sdk-version",
9
+ });
10
+ return {
11
+ fetchCatalog: vi.fn(),
12
+ fetchJSON: vi.fn(),
13
+ };
14
+ });
15
+
16
+ vi.mock("./releaseCatalogCache", () => ({
17
+ fetchReleaseCatalogWithCache: mocks.fetchCatalog,
18
+ }));
19
+
20
+ vi.mock("./fetchJSON", () => ({
21
+ fetchJSON: mocks.fetchJSON,
22
+ }));
23
+
24
+ const artifact: ArtifactInfo = {
25
+ fileHash: "bundle-hash",
26
+ fileUrl: "/storage/bundle.zip",
27
+ };
28
+
29
+ const catalog: ReleaseCatalog = {
30
+ catalogId: "server-owned-project",
31
+ catalogHash: `sha256:${"a".repeat(64)}`,
32
+ fallbackPolicy: "BUILTIN_IF_ACTIVE_INELIGIBLE",
33
+ generation: 1,
34
+ releases: [],
35
+ schemaVersion: 1,
36
+ scopeKey: "v1:app-version:server-owned-project:ios:cHJvZHVjdGlvbg",
37
+ };
38
+
39
+ describe("private HotUpdater HTTP client", () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ mocks.fetchCatalog.mockResolvedValue(catalog);
43
+ mocks.fetchJSON.mockResolvedValue(artifact);
44
+ });
45
+
46
+ it("fetches a catalog without putting its internal identity in the route", async () => {
47
+ const session = await createHttpClient(
48
+ "https://updates.example.com/hot-updater/",
49
+ ).createSession();
50
+
51
+ await expect(
52
+ session.fetchReleaseCatalog({
53
+ appVersion: "1.2.0",
54
+ channel: "production",
55
+ fingerprintHash: null,
56
+ platform: "ios",
57
+ updateStrategy: "appVersion",
58
+ }),
59
+ ).resolves.toBe(catalog);
60
+
61
+ expect(mocks.fetchCatalog).toHaveBeenCalledWith({
62
+ baseURL: "https://updates.example.com/hot-updater",
63
+ expectedScope: {
64
+ channelKey: "cHJvZHVjdGlvbg",
65
+ platform: "ios",
66
+ strategy: "APP_VERSION",
67
+ },
68
+ requestHeaders: undefined,
69
+ requestTimeout: undefined,
70
+ url: "https://updates.example.com/hot-updater/release-catalogs/app-version/ios/cHJvZHVjdGlvbg/1.2.0",
71
+ });
72
+ });
73
+
74
+ it("resolves a functional baseURL once per session and captures it for a deferred artifact", async () => {
75
+ const resolveBaseURL = vi
76
+ .fn()
77
+ .mockResolvedValueOnce("https://first.example.com/hot-updater")
78
+ .mockResolvedValueOnce("https://second.example.com/hot-updater");
79
+ const client = createHttpClient(resolveBaseURL);
80
+ const firstSession = await client.createSession();
81
+
82
+ await firstSession.fetchReleaseCatalog({
83
+ appVersion: "1.2.0",
84
+ channel: "production",
85
+ fingerprintHash: null,
86
+ platform: "ios",
87
+ updateStrategy: "appVersion",
88
+ });
89
+ await expect(
90
+ firstSession.resolveArtifact({
91
+ currentBundleId: "current",
92
+ targetBundleId: "target",
93
+ }),
94
+ ).resolves.toEqual({
95
+ ...artifact,
96
+ fileUrl: "https://first.example.com/hot-updater/storage/bundle.zip",
97
+ });
98
+
99
+ expect(resolveBaseURL).toHaveBeenCalledOnce();
100
+ expect(mocks.fetchJSON).toHaveBeenCalledWith({
101
+ requestHeaders: undefined,
102
+ requestTimeout: undefined,
103
+ url: "https://first.example.com/hot-updater/artifacts/target/from/current",
104
+ });
105
+
106
+ await client.createSession();
107
+ expect(resolveBaseURL).toHaveBeenCalledTimes(2);
108
+ });
109
+
110
+ it("rejects non-storage relative artifact URLs", async () => {
111
+ mocks.fetchJSON.mockResolvedValue({
112
+ ...artifact,
113
+ fileUrl: "/private/bundle.zip",
114
+ });
115
+ const session = await createHttpClient(
116
+ "https://updates.example.com",
117
+ ).createSession();
118
+
119
+ await expect(
120
+ session.resolveArtifact({
121
+ currentBundleId: "current",
122
+ targetBundleId: "target",
123
+ }),
124
+ ).rejects.toThrow("client-relative storage paths");
125
+ });
126
+
127
+ it("requires a functional baseURL to resolve to a non-empty string", async () => {
128
+ await expect(createHttpClient(() => "").createSession()).rejects.toThrow(
129
+ "baseURL function must return a non-empty string",
130
+ );
131
+ });
132
+ });