@indraq-ota/react-native-sdk 0.1.0-mvp
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 +8 -0
- package/index.d.ts +76 -0
- package/index.js +261 -0
- package/package.json +28 -0
package/README.md
ADDED
package/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type IndraqOtaPlatform = 'android' | 'ios';
|
|
2
|
+
|
|
3
|
+
export type CheckForUpdateInput = {
|
|
4
|
+
serverUrl: string;
|
|
5
|
+
appSlug: string;
|
|
6
|
+
platform: IndraqOtaPlatform;
|
|
7
|
+
channel: string;
|
|
8
|
+
runtimeVersion: string;
|
|
9
|
+
currentLabel?: string;
|
|
10
|
+
appVersion?: string;
|
|
11
|
+
deviceId?: string;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
extra?: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type OtaRelease = {
|
|
17
|
+
releaseId?: string;
|
|
18
|
+
id?: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
appSlug?: string;
|
|
21
|
+
platform?: string;
|
|
22
|
+
channel?: string;
|
|
23
|
+
runtimeVersion?: string;
|
|
24
|
+
bundleUrl?: string;
|
|
25
|
+
bundleSha256?: string;
|
|
26
|
+
bundleHash?: string;
|
|
27
|
+
assetsUrl?: string;
|
|
28
|
+
assetsSha256?: string;
|
|
29
|
+
assetsHash?: string;
|
|
30
|
+
mandatory?: boolean;
|
|
31
|
+
rolloutPercentage?: number;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type CheckForUpdateResult = {
|
|
36
|
+
updateAvailable: boolean;
|
|
37
|
+
release?: OtaRelease | null;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type DownloadUpdateResult = {
|
|
42
|
+
success: boolean;
|
|
43
|
+
release: OtaRelease;
|
|
44
|
+
releaseDir: string;
|
|
45
|
+
bundlePath: string;
|
|
46
|
+
assetsZipPath?: string | null;
|
|
47
|
+
assetsDir?: string | null;
|
|
48
|
+
bundleSha256?: string | null;
|
|
49
|
+
assetsSha256?: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type InstallUpdateResult = {
|
|
53
|
+
success: boolean;
|
|
54
|
+
currentManifestPath: string;
|
|
55
|
+
bundlePath: string;
|
|
56
|
+
release: OtaRelease;
|
|
57
|
+
note: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type IndraqOTAApi = {
|
|
61
|
+
checkForUpdate(input: CheckForUpdateInput): Promise<CheckForUpdateResult>;
|
|
62
|
+
downloadUpdate(release: OtaRelease): Promise<DownloadUpdateResult>;
|
|
63
|
+
installUpdate(): Promise<InstallUpdateResult>;
|
|
64
|
+
getCurrentUpdate(): Promise<any | null>;
|
|
65
|
+
getPendingUpdate(): Promise<any | null>;
|
|
66
|
+
clearUpdate(): Promise<{ success: true }>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
declare const IndraqOTA: IndraqOTAApi;
|
|
70
|
+
export default IndraqOTA;
|
|
71
|
+
export const checkForUpdate: IndraqOTAApi['checkForUpdate'];
|
|
72
|
+
export const downloadUpdate: IndraqOTAApi['downloadUpdate'];
|
|
73
|
+
export const installUpdate: IndraqOTAApi['installUpdate'];
|
|
74
|
+
export const getCurrentUpdate: IndraqOTAApi['getCurrentUpdate'];
|
|
75
|
+
export const getPendingUpdate: IndraqOTAApi['getPendingUpdate'];
|
|
76
|
+
export const clearUpdate: IndraqOTAApi['clearUpdate'];
|
package/index.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Platform, DevSettings } = require('react-native');
|
|
4
|
+
const RNFS = require('react-native-fs');
|
|
5
|
+
|
|
6
|
+
let ZipArchive = null;
|
|
7
|
+
try {
|
|
8
|
+
ZipArchive = require('react-native-zip-archive');
|
|
9
|
+
} catch (_) {
|
|
10
|
+
ZipArchive = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const OTA_ROOT = `${RNFS.DocumentDirectoryPath}/indraq-ota`;
|
|
14
|
+
const RELEASES_DIR = `${OTA_ROOT}/releases`;
|
|
15
|
+
const PENDING_MANIFEST = `${OTA_ROOT}/pending.json`;
|
|
16
|
+
const CURRENT_MANIFEST = `${OTA_ROOT}/current.json`;
|
|
17
|
+
|
|
18
|
+
function cleanBaseUrl(serverUrl) {
|
|
19
|
+
if (!serverUrl || typeof serverUrl !== 'string') {
|
|
20
|
+
throw new Error('serverUrl is required');
|
|
21
|
+
}
|
|
22
|
+
return serverUrl.replace(/\/+$/, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pick(obj, keys) {
|
|
26
|
+
for (const key of keys) {
|
|
27
|
+
if (obj && obj[key]) return obj[key];
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeSegment(value, fallback) {
|
|
33
|
+
return String(value || fallback || 'release')
|
|
34
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
35
|
+
.slice(0, 120);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function ensureDir(dir) {
|
|
39
|
+
const exists = await RNFS.exists(dir);
|
|
40
|
+
if (!exists) await RNFS.mkdir(dir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function writeJson(path, value) {
|
|
44
|
+
await ensureDir(OTA_ROOT);
|
|
45
|
+
await RNFS.writeFile(path, JSON.stringify(value, null, 2), 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readJson(path) {
|
|
49
|
+
const exists = await RNFS.exists(path);
|
|
50
|
+
if (!exists) return null;
|
|
51
|
+
const raw = await RNFS.readFile(path, 'utf8');
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function sha256(filePath) {
|
|
56
|
+
return RNFS.hash(filePath, 'sha256');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function removeIfExists(path) {
|
|
60
|
+
const exists = await RNFS.exists(path);
|
|
61
|
+
if (exists) await RNFS.unlink(path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function downloadToFile(url, toFile, expectedSha256) {
|
|
65
|
+
if (!url) throw new Error('download url is missing');
|
|
66
|
+
|
|
67
|
+
await ensureDir(toFile.substring(0, toFile.lastIndexOf('/')));
|
|
68
|
+
await removeIfExists(toFile);
|
|
69
|
+
|
|
70
|
+
const result = await RNFS.downloadFile({
|
|
71
|
+
fromUrl: url,
|
|
72
|
+
toFile,
|
|
73
|
+
background: false,
|
|
74
|
+
discretionary: false,
|
|
75
|
+
}).promise;
|
|
76
|
+
|
|
77
|
+
if (!result || result.statusCode < 200 || result.statusCode >= 300) {
|
|
78
|
+
throw new Error(`Download failed with HTTP ${result ? result.statusCode : 'unknown'}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const actualSha = await sha256(toFile);
|
|
82
|
+
if (expectedSha256 && actualSha.toLowerCase() !== String(expectedSha256).toLowerCase()) {
|
|
83
|
+
await removeIfExists(toFile);
|
|
84
|
+
throw new Error(`SHA256 mismatch for ${url}. expected=${expectedSha256} actual=${actualSha}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return actualSha;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function checkForUpdate(input) {
|
|
91
|
+
const serverUrl = cleanBaseUrl(input.serverUrl);
|
|
92
|
+
const body = {
|
|
93
|
+
appSlug: input.appSlug,
|
|
94
|
+
platform: input.platform || Platform.OS,
|
|
95
|
+
channel: input.channel,
|
|
96
|
+
runtimeVersion: input.runtimeVersion,
|
|
97
|
+
currentLabel: input.currentLabel,
|
|
98
|
+
appVersion: input.appVersion,
|
|
99
|
+
deviceId: input.deviceId,
|
|
100
|
+
...(input.extra || {}),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const headers = {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (input.apiKey) {
|
|
108
|
+
headers['x-ota-api-key'] = input.apiKey;
|
|
109
|
+
headers['x-api-key'] = input.apiKey;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const response = await fetch(`${serverUrl}/api/v1/updates/check`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers,
|
|
115
|
+
body: JSON.stringify(body),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const text = await response.text();
|
|
119
|
+
let json = null;
|
|
120
|
+
try {
|
|
121
|
+
json = text ? JSON.parse(text) : null;
|
|
122
|
+
} catch (_) {
|
|
123
|
+
json = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
const msg = json?.message || json?.error || text || `Update check failed with HTTP ${response.status}`;
|
|
128
|
+
throw new Error(msg);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const data = json?.data || json;
|
|
132
|
+
return data || { updateAvailable: false, release: null };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function downloadUpdate(release) {
|
|
136
|
+
if (!release || typeof release !== 'object') {
|
|
137
|
+
throw new Error('release is required');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const bundleUrl = pick(release, ['bundleUrl', 'bundleURL', 'bundleDownloadUrl']);
|
|
141
|
+
const assetsUrl = pick(release, ['assetsUrl', 'assetsURL', 'assetsZipUrl', 'assetsDownloadUrl']);
|
|
142
|
+
const bundleExpectedSha = pick(release, ['bundleSha256', 'bundleSHA256', 'bundleHash', 'hash']);
|
|
143
|
+
const assetsExpectedSha = pick(release, ['assetsSha256', 'assetsSHA256', 'assetsHash']);
|
|
144
|
+
|
|
145
|
+
if (!bundleUrl) {
|
|
146
|
+
throw new Error('release.bundleUrl is missing');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const releaseKey = safeSegment(release.label || release.releaseId || release.id, Date.now());
|
|
150
|
+
const releaseDir = `${RELEASES_DIR}/${releaseKey}`;
|
|
151
|
+
const bundlePath = `${releaseDir}/index.${Platform.OS}.bundle`;
|
|
152
|
+
const assetsZipPath = assetsUrl ? `${releaseDir}/assets.zip` : null;
|
|
153
|
+
const assetsDir = assetsUrl ? `${releaseDir}/assets` : null;
|
|
154
|
+
|
|
155
|
+
await ensureDir(OTA_ROOT);
|
|
156
|
+
await ensureDir(RELEASES_DIR);
|
|
157
|
+
await ensureDir(releaseDir);
|
|
158
|
+
|
|
159
|
+
const bundleActualSha = await downloadToFile(bundleUrl, bundlePath, bundleExpectedSha);
|
|
160
|
+
|
|
161
|
+
let assetsActualSha = null;
|
|
162
|
+
if (assetsUrl) {
|
|
163
|
+
assetsActualSha = await downloadToFile(assetsUrl, assetsZipPath, assetsExpectedSha);
|
|
164
|
+
|
|
165
|
+
if (ZipArchive && typeof ZipArchive.unzip === 'function') {
|
|
166
|
+
await ensureDir(assetsDir);
|
|
167
|
+
await ZipArchive.unzip(assetsZipPath, assetsDir);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const manifest = {
|
|
172
|
+
downloadedAt: new Date().toISOString(),
|
|
173
|
+
release,
|
|
174
|
+
releaseDir,
|
|
175
|
+
bundlePath,
|
|
176
|
+
assetsZipPath,
|
|
177
|
+
assetsDir,
|
|
178
|
+
bundleSha256: bundleActualSha,
|
|
179
|
+
assetsSha256: assetsActualSha,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
await writeJson(PENDING_MANIFEST, manifest);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
release,
|
|
187
|
+
releaseDir,
|
|
188
|
+
bundlePath,
|
|
189
|
+
assetsZipPath,
|
|
190
|
+
assetsDir,
|
|
191
|
+
bundleSha256: bundleActualSha,
|
|
192
|
+
assetsSha256: assetsActualSha,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function installUpdate(options = {}) {
|
|
197
|
+
const pending = await readJson(PENDING_MANIFEST);
|
|
198
|
+
if (!pending || !pending.bundlePath) {
|
|
199
|
+
throw new Error('No pending OTA update found. Download update first.');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const exists = await RNFS.exists(pending.bundlePath);
|
|
203
|
+
if (!exists) {
|
|
204
|
+
throw new Error(`Pending bundle file not found: ${pending.bundlePath}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const currentManifest = {
|
|
208
|
+
...pending,
|
|
209
|
+
installedAt: new Date().toISOString(),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
await writeJson(CURRENT_MANIFEST, currentManifest);
|
|
213
|
+
await removeIfExists(PENDING_MANIFEST);
|
|
214
|
+
|
|
215
|
+
const shouldReload = options?.reload === true;
|
|
216
|
+
|
|
217
|
+
if (shouldReload && DevSettings && typeof DevSettings.reload === 'function') {
|
|
218
|
+
setTimeout(() => DevSettings.reload(), 300);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
success: true,
|
|
223
|
+
currentManifestPath: CURRENT_MANIFEST,
|
|
224
|
+
bundlePath: pending.bundlePath,
|
|
225
|
+
release: pending.release,
|
|
226
|
+
installedLabel: pending.release?.label || null,
|
|
227
|
+
note: 'Update marked as current. Native loader integration is required to boot from downloaded bundle.',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function getCurrentUpdate() {
|
|
232
|
+
return readJson(CURRENT_MANIFEST);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function getPendingUpdate() {
|
|
236
|
+
return readJson(PENDING_MANIFEST);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function clearUpdate() {
|
|
240
|
+
await removeIfExists(PENDING_MANIFEST);
|
|
241
|
+
await removeIfExists(CURRENT_MANIFEST);
|
|
242
|
+
return { success: true };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const api = {
|
|
246
|
+
checkForUpdate,
|
|
247
|
+
downloadUpdate,
|
|
248
|
+
installUpdate,
|
|
249
|
+
getCurrentUpdate,
|
|
250
|
+
getPendingUpdate,
|
|
251
|
+
clearUpdate,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
module.exports = api;
|
|
255
|
+
module.exports.default = api;
|
|
256
|
+
module.exports.checkForUpdate = checkForUpdate;
|
|
257
|
+
module.exports.downloadUpdate = downloadUpdate;
|
|
258
|
+
module.exports.installUpdate = installUpdate;
|
|
259
|
+
module.exports.getCurrentUpdate = getCurrentUpdate;
|
|
260
|
+
module.exports.getPendingUpdate = getPendingUpdate;
|
|
261
|
+
module.exports.clearUpdate = clearUpdate;
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@indraq-ota/react-native-sdk",
|
|
3
|
+
"version": "0.1.0-mvp",
|
|
4
|
+
"description": "IndraQ OTA React Native SDK MVP for checking, downloading and installing OTA JavaScript bundle updates.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"react-native": "index.js",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"index.d.ts",
|
|
12
|
+
"package.json",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"react-native",
|
|
17
|
+
"ota",
|
|
18
|
+
"over-the-air",
|
|
19
|
+
"updates",
|
|
20
|
+
"indraq"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "*",
|
|
24
|
+
"react-native": "*",
|
|
25
|
+
"react-native-fs": "*",
|
|
26
|
+
"react-native-zip-archive": "*"
|
|
27
|
+
}
|
|
28
|
+
}
|