@take-out/native-hot-update 0.0.36
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.
- package/README.md +171 -0
- package/dist/cjs/createHotUpdater.cjs +183 -0
- package/dist/cjs/createHotUpdater.js +146 -0
- package/dist/cjs/createHotUpdater.js.map +6 -0
- package/dist/cjs/createHotUpdater.native.js +213 -0
- package/dist/cjs/createHotUpdater.native.js.map +1 -0
- package/dist/cjs/index.cjs +26 -0
- package/dist/cjs/index.js +21 -0
- package/dist/cjs/index.js.map +6 -0
- package/dist/cjs/index.native.js +29 -0
- package/dist/cjs/index.native.js.map +1 -0
- package/dist/cjs/mmkv.cjs +32 -0
- package/dist/cjs/mmkv.js +27 -0
- package/dist/cjs/mmkv.js.map +6 -0
- package/dist/cjs/mmkv.native.js +41 -0
- package/dist/cjs/mmkv.native.js.map +1 -0
- package/dist/cjs/types.cjs +16 -0
- package/dist/cjs/types.js +14 -0
- package/dist/cjs/types.js.map +6 -0
- package/dist/cjs/types.native.js +19 -0
- package/dist/cjs/types.native.js.map +1 -0
- package/dist/esm/createHotUpdater.js +129 -0
- package/dist/esm/createHotUpdater.js.map +6 -0
- package/dist/esm/createHotUpdater.mjs +149 -0
- package/dist/esm/createHotUpdater.mjs.map +1 -0
- package/dist/esm/createHotUpdater.native.js +176 -0
- package/dist/esm/createHotUpdater.native.js.map +1 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +6 -0
- package/dist/esm/index.mjs +3 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/index.native.js +3 -0
- package/dist/esm/index.native.js.map +1 -0
- package/dist/esm/mmkv.js +11 -0
- package/dist/esm/mmkv.js.map +6 -0
- package/dist/esm/mmkv.mjs +9 -0
- package/dist/esm/mmkv.mjs.map +1 -0
- package/dist/esm/mmkv.native.js +15 -0
- package/dist/esm/mmkv.native.js.map +1 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/types.js.map +6 -0
- package/dist/esm/types.mjs +2 -0
- package/dist/esm/types.mjs.map +1 -0
- package/dist/esm/types.native.js +2 -0
- package/dist/esm/types.native.js.map +1 -0
- package/package.json +63 -0
- package/src/createHotUpdater.ts +240 -0
- package/src/index.ts +2 -0
- package/src/mmkv.ts +20 -0
- package/src/types.ts +62 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getUpdateSource,
|
|
3
|
+
HotUpdater,
|
|
4
|
+
useHotUpdaterStore,
|
|
5
|
+
} from '@hot-updater/react-native'
|
|
6
|
+
import * as Application from 'expo-application'
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { Alert } from 'react-native'
|
|
9
|
+
|
|
10
|
+
import type { HotUpdaterConfig, HotUpdaterInstance, UpdateInfo } from './types'
|
|
11
|
+
|
|
12
|
+
const INITIAL_OTA_ID = '00000000-0000-0000-0000-000000000000'
|
|
13
|
+
const BUNDLE_ID_KEY_PREFIX = 'hotUpdater.bundleId'
|
|
14
|
+
const PRE_RELEASE_BUNDLE_ID_KEY = 'hotUpdater.preReleaseBundleId'
|
|
15
|
+
|
|
16
|
+
export function createHotUpdater(config: HotUpdaterConfig): HotUpdaterInstance {
|
|
17
|
+
const { serverUrl, storage, updateStrategy = 'appVersion' } = config
|
|
18
|
+
|
|
19
|
+
let isUpdatePending = false
|
|
20
|
+
|
|
21
|
+
const getAppliedOta = (): string | null => {
|
|
22
|
+
const id = HotUpdater.getBundleId()
|
|
23
|
+
if (id === INITIAL_OTA_ID) return null
|
|
24
|
+
if (id === HotUpdater.getMinBundleId()) return null
|
|
25
|
+
return id
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const getShortOtaId = (): string | null => {
|
|
29
|
+
const fullId = getAppliedOta()
|
|
30
|
+
return fullId ? fullId.slice(24) : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const getIsUpdatePending = (): boolean => {
|
|
34
|
+
return isUpdatePending
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const getPreReleaseBundleId = (): string | undefined => {
|
|
38
|
+
return storage.get(PRE_RELEASE_BUNDLE_ID_KEY)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const handleUpdateDownloaded = (
|
|
42
|
+
id: string,
|
|
43
|
+
options: { isPreRelease?: boolean } = {}
|
|
44
|
+
) => {
|
|
45
|
+
const appVersion = Application.nativeApplicationVersion
|
|
46
|
+
if (appVersion) {
|
|
47
|
+
storage.set(`${BUNDLE_ID_KEY_PREFIX}.${appVersion}`, id)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.isPreRelease) {
|
|
51
|
+
storage.set(PRE_RELEASE_BUNDLE_ID_KEY, id)
|
|
52
|
+
} else {
|
|
53
|
+
storage.delete(PRE_RELEASE_BUNDLE_ID_KEY)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isUpdatePending = true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const useOtaUpdater = (
|
|
60
|
+
options: {
|
|
61
|
+
enabled?: boolean
|
|
62
|
+
onUpdateDownloaded?: (info: UpdateInfo) => void
|
|
63
|
+
onError?: (error: unknown) => void
|
|
64
|
+
} = {}
|
|
65
|
+
) => {
|
|
66
|
+
const {
|
|
67
|
+
enabled = true,
|
|
68
|
+
onUpdateDownloaded: onUpdateDownloadedCallback,
|
|
69
|
+
onError,
|
|
70
|
+
} = options
|
|
71
|
+
|
|
72
|
+
const progress = useHotUpdaterStore((state) => state.progress)
|
|
73
|
+
const isUpdating = progress > 0.01
|
|
74
|
+
|
|
75
|
+
const [isUserClearedForAccess, setIsUserClearedForAccess] = useState(false)
|
|
76
|
+
const isUserClearedForAccessRef = useRef(isUserClearedForAccess)
|
|
77
|
+
isUserClearedForAccessRef.current = isUserClearedForAccess
|
|
78
|
+
|
|
79
|
+
// 5s timeout if no update started
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!isUpdating) {
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
if (!isUserClearedForAccessRef.current) {
|
|
84
|
+
setIsUserClearedForAccess(true)
|
|
85
|
+
}
|
|
86
|
+
}, 5000)
|
|
87
|
+
return () => clearTimeout(timer)
|
|
88
|
+
}
|
|
89
|
+
}, [isUpdating])
|
|
90
|
+
|
|
91
|
+
// 20s hard timeout
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
if (!isUserClearedForAccessRef.current) {
|
|
95
|
+
setIsUserClearedForAccess(true)
|
|
96
|
+
}
|
|
97
|
+
}, 20000)
|
|
98
|
+
return () => clearTimeout(timer)
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
// Update check
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
let shouldContinue = true
|
|
104
|
+
|
|
105
|
+
;(async () => {
|
|
106
|
+
try {
|
|
107
|
+
if (!enabled) {
|
|
108
|
+
setIsUserClearedForAccess(true)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!shouldContinue) return
|
|
113
|
+
|
|
114
|
+
const updateInfo = await HotUpdater.checkForUpdate({
|
|
115
|
+
source: getUpdateSource(serverUrl, { updateStrategy }),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
if (!updateInfo) {
|
|
119
|
+
setIsUserClearedForAccess(true)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!shouldContinue) return
|
|
124
|
+
|
|
125
|
+
// Handle rollback for pre-release bundles
|
|
126
|
+
if (updateInfo.status === 'ROLLBACK') {
|
|
127
|
+
const preReleaseBundleId = getPreReleaseBundleId()
|
|
128
|
+
const currentBundleId = HotUpdater.getBundleId()
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
preReleaseBundleId &&
|
|
132
|
+
preReleaseBundleId === currentBundleId &&
|
|
133
|
+
currentBundleId > updateInfo.id
|
|
134
|
+
) {
|
|
135
|
+
Alert.alert(
|
|
136
|
+
'Update Skipped',
|
|
137
|
+
'Skipped rollback because you are using a newer pre-release bundle.'
|
|
138
|
+
)
|
|
139
|
+
setIsUserClearedForAccess(true)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!updateInfo.shouldForceUpdate) {
|
|
145
|
+
setIsUserClearedForAccess(true)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!shouldContinue) return
|
|
149
|
+
|
|
150
|
+
await updateInfo.updateBundle()
|
|
151
|
+
handleUpdateDownloaded(updateInfo.id)
|
|
152
|
+
|
|
153
|
+
const info: UpdateInfo = {
|
|
154
|
+
id: updateInfo.id,
|
|
155
|
+
isCriticalUpdate: updateInfo.shouldForceUpdate,
|
|
156
|
+
fileUrl: updateInfo.fileUrl,
|
|
157
|
+
message: updateInfo.message,
|
|
158
|
+
}
|
|
159
|
+
onUpdateDownloadedCallback?.(info)
|
|
160
|
+
|
|
161
|
+
if (!shouldContinue) return
|
|
162
|
+
|
|
163
|
+
if (updateInfo.shouldForceUpdate) {
|
|
164
|
+
if (!isUserClearedForAccessRef.current) {
|
|
165
|
+
HotUpdater.reload()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
Alert.alert(
|
|
169
|
+
'Update Downloaded',
|
|
170
|
+
'An important update has been downloaded. Reload now to apply it?',
|
|
171
|
+
[
|
|
172
|
+
{ text: 'Later', style: 'cancel' },
|
|
173
|
+
{ text: 'Reload Now', onPress: () => HotUpdater.reload() },
|
|
174
|
+
]
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
onError?.(error)
|
|
179
|
+
setIsUserClearedForAccess(true)
|
|
180
|
+
} finally {
|
|
181
|
+
if (!isUserClearedForAccessRef.current) {
|
|
182
|
+
setIsUserClearedForAccess(true)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
})()
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
shouldContinue = false
|
|
189
|
+
}
|
|
190
|
+
}, [enabled, onUpdateDownloadedCallback, onError])
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
userClearedForAccess: isUserClearedForAccess,
|
|
194
|
+
progress,
|
|
195
|
+
isUpdatePending: getIsUpdatePending(),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const checkForUpdate = async (
|
|
200
|
+
options: { channel?: string; isPreRelease?: boolean } = {}
|
|
201
|
+
) => {
|
|
202
|
+
const { channel, isPreRelease = false } = options
|
|
203
|
+
|
|
204
|
+
const updateSource = getUpdateSource(serverUrl, { updateStrategy })
|
|
205
|
+
|
|
206
|
+
const finalSource = channel
|
|
207
|
+
? (params: any) => updateSource({ ...params, channel })
|
|
208
|
+
: updateSource
|
|
209
|
+
|
|
210
|
+
const updateInfo = await HotUpdater.checkForUpdate({
|
|
211
|
+
source: finalSource,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (updateInfo) {
|
|
215
|
+
await updateInfo.updateBundle()
|
|
216
|
+
handleUpdateDownloaded(updateInfo.id, { isPreRelease })
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
id: updateInfo.id,
|
|
220
|
+
isCriticalUpdate: updateInfo.shouldForceUpdate,
|
|
221
|
+
fileUrl: updateInfo.fileUrl,
|
|
222
|
+
message: updateInfo.message,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
useOtaUpdater,
|
|
231
|
+
checkForUpdate,
|
|
232
|
+
getAppliedOta,
|
|
233
|
+
getShortOtaId,
|
|
234
|
+
getIsUpdatePending,
|
|
235
|
+
reload: () => HotUpdater.reload(),
|
|
236
|
+
getBundleId: () => HotUpdater.getBundleId(),
|
|
237
|
+
getMinBundleId: () => HotUpdater.getMinBundleId(),
|
|
238
|
+
getChannel: () => HotUpdater.getChannel(),
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/index.ts
ADDED
package/src/mmkv.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HotUpdateStorage } from './types'
|
|
2
|
+
import type { MMKV } from 'react-native-mmkv'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a storage adapter for MMKV.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { MMKV } from 'react-native-mmkv'
|
|
9
|
+
* import { createMMKVStorage } from '@take-out/native-hot-update/mmkv'
|
|
10
|
+
*
|
|
11
|
+
* const mmkv = new MMKV({ id: 'hot-updater' })
|
|
12
|
+
* const storage = createMMKVStorage(mmkv)
|
|
13
|
+
*/
|
|
14
|
+
export function createMMKVStorage(mmkv: MMKV): HotUpdateStorage {
|
|
15
|
+
return {
|
|
16
|
+
get: (key: string) => mmkv.getString(key),
|
|
17
|
+
set: (key: string, value: string) => mmkv.set(key, value),
|
|
18
|
+
delete: (key: string) => mmkv.delete(key),
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Simple key-value storage interface */
|
|
2
|
+
export interface HotUpdateStorage {
|
|
3
|
+
get(key: string): string | undefined
|
|
4
|
+
set(key: string, value: string): void
|
|
5
|
+
delete(key: string): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HotUpdaterConfig {
|
|
9
|
+
/** Hot update server URL */
|
|
10
|
+
serverUrl: string
|
|
11
|
+
/** Storage for persisting update state */
|
|
12
|
+
storage: HotUpdateStorage
|
|
13
|
+
/** Update strategy (default: 'appVersion') */
|
|
14
|
+
updateStrategy?: 'appVersion' | 'fingerprint'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UpdateInfo {
|
|
18
|
+
id: string
|
|
19
|
+
isCriticalUpdate: boolean
|
|
20
|
+
fileUrl: string | null
|
|
21
|
+
message: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HotUpdaterInstance {
|
|
25
|
+
/** React hook for automatic update checking */
|
|
26
|
+
useOtaUpdater: (options?: {
|
|
27
|
+
enabled?: boolean
|
|
28
|
+
onUpdateDownloaded?: (info: UpdateInfo) => void
|
|
29
|
+
onError?: (error: unknown) => void
|
|
30
|
+
}) => {
|
|
31
|
+
userClearedForAccess: boolean
|
|
32
|
+
progress: number
|
|
33
|
+
isUpdatePending: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Manually check for updates (for dev/testing) */
|
|
37
|
+
checkForUpdate: (options?: {
|
|
38
|
+
channel?: string
|
|
39
|
+
isPreRelease?: boolean
|
|
40
|
+
}) => Promise<UpdateInfo | null>
|
|
41
|
+
|
|
42
|
+
/** Get currently applied OTA bundle ID (null if native) */
|
|
43
|
+
getAppliedOta: () => string | null
|
|
44
|
+
|
|
45
|
+
/** Get short version of OTA ID */
|
|
46
|
+
getShortOtaId: () => string | null
|
|
47
|
+
|
|
48
|
+
/** Check if update is pending (will apply on restart) */
|
|
49
|
+
getIsUpdatePending: () => boolean
|
|
50
|
+
|
|
51
|
+
/** Reload the app to apply update */
|
|
52
|
+
reload: () => void
|
|
53
|
+
|
|
54
|
+
/** Get current bundle ID */
|
|
55
|
+
getBundleId: () => string
|
|
56
|
+
|
|
57
|
+
/** Get minimum bundle ID (native build time) */
|
|
58
|
+
getMinBundleId: () => string
|
|
59
|
+
|
|
60
|
+
/** Get current channel */
|
|
61
|
+
getChannel: () => string
|
|
62
|
+
}
|