@comergehq/studio 0.1.13 → 0.1.15
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/dist/index.d.mts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +211 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +211 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/data/apps/bundles/remote.ts +17 -0
- package/src/data/apps/bundles/repository.ts +14 -0
- package/src/data/apps/bundles/types.ts +15 -0
- package/src/studio/hooks/useBundleManager.ts +273 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comergehq/studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Comerge studio",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"react-native": "*",
|
|
69
69
|
"react-native-safe-area-context": "*",
|
|
70
70
|
"react-native-svg": "*",
|
|
71
|
+
"react-native-zip-archive": "*",
|
|
71
72
|
"react-native-view-shot": "*"
|
|
72
73
|
},
|
|
73
74
|
"peerDependenciesMeta": {}
|
|
@@ -11,6 +11,11 @@ export interface BundlesRemoteDataSource {
|
|
|
11
11
|
bundleId: string,
|
|
12
12
|
options?: { redirect?: boolean }
|
|
13
13
|
): Promise<ServiceResponse<{ url: string; redirect: boolean }>>;
|
|
14
|
+
getSignedAssetsDownloadUrl(
|
|
15
|
+
appId: string,
|
|
16
|
+
bundleId: string,
|
|
17
|
+
options?: { redirect?: boolean; kind?: string }
|
|
18
|
+
): Promise<ServiceResponse<{ url: string; redirect: boolean }>>;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
class BundlesRemoteDataSourceImpl extends BaseRemote implements BundlesRemoteDataSource {
|
|
@@ -40,6 +45,18 @@ class BundlesRemoteDataSourceImpl extends BaseRemote implements BundlesRemoteDat
|
|
|
40
45
|
);
|
|
41
46
|
return data;
|
|
42
47
|
}
|
|
48
|
+
|
|
49
|
+
async getSignedAssetsDownloadUrl(
|
|
50
|
+
appId: string,
|
|
51
|
+
bundleId: string,
|
|
52
|
+
options?: { redirect?: boolean; kind?: string }
|
|
53
|
+
): Promise<ServiceResponse<{ url: string; redirect: boolean }>> {
|
|
54
|
+
const { data } = await api.get<ServiceResponse<{ url: string; redirect: boolean }>>(
|
|
55
|
+
`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}/assets/download`,
|
|
56
|
+
{ params: { redirect: options?.redirect ?? false, kind: options?.kind } }
|
|
57
|
+
);
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
export const bundlesRemoteDataSource: BundlesRemoteDataSource = new BundlesRemoteDataSourceImpl();
|
|
@@ -7,6 +7,11 @@ export interface BundlesRepository {
|
|
|
7
7
|
initiate(appId: string, payload: InitiateBundleRequest): Promise<Bundle>;
|
|
8
8
|
getById(appId: string, bundleId: string): Promise<Bundle>;
|
|
9
9
|
getSignedDownloadUrl(appId: string, bundleId: string, options?: { redirect?: boolean }): Promise<{ url: string; redirect: boolean }>;
|
|
10
|
+
getSignedAssetsDownloadUrl(
|
|
11
|
+
appId: string,
|
|
12
|
+
bundleId: string,
|
|
13
|
+
options?: { redirect?: boolean; kind?: string }
|
|
14
|
+
): Promise<{ url: string; redirect: boolean }>;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
class BundlesRepositoryImpl extends BaseRepository implements BundlesRepository {
|
|
@@ -28,6 +33,15 @@ class BundlesRepositoryImpl extends BaseRepository implements BundlesRepository
|
|
|
28
33
|
const res = await this.remote.getSignedDownloadUrl(appId, bundleId, options);
|
|
29
34
|
return this.unwrapOrThrow(res);
|
|
30
35
|
}
|
|
36
|
+
|
|
37
|
+
async getSignedAssetsDownloadUrl(
|
|
38
|
+
appId: string,
|
|
39
|
+
bundleId: string,
|
|
40
|
+
options?: { redirect?: boolean; kind?: string }
|
|
41
|
+
): Promise<{ url: string; redirect: boolean }> {
|
|
42
|
+
const res = await this.remote.getSignedAssetsDownloadUrl(appId, bundleId, options);
|
|
43
|
+
return this.unwrapOrThrow(res);
|
|
44
|
+
}
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
export const bundlesRepository: BundlesRepository = new BundlesRepositoryImpl(bundlesRemoteDataSource);
|
|
@@ -2,6 +2,20 @@ export type Platform = 'ios' | 'android';
|
|
|
2
2
|
|
|
3
3
|
export type BundleStatus = 'pending' | 'building' | 'succeeded' | 'failed';
|
|
4
4
|
|
|
5
|
+
export type BundleAssetKind = 'metro-assets' | string;
|
|
6
|
+
|
|
7
|
+
export type BundleAsset = {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: BundleAssetKind;
|
|
10
|
+
storageBucket: string;
|
|
11
|
+
storageKey: string;
|
|
12
|
+
contentType: string | null;
|
|
13
|
+
size: number | null;
|
|
14
|
+
checksumSha256: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
5
19
|
export type Bundle = {
|
|
6
20
|
id: string;
|
|
7
21
|
appId: string;
|
|
@@ -15,6 +29,7 @@ export type Bundle = {
|
|
|
15
29
|
createdAt: string;
|
|
16
30
|
updatedAt: string;
|
|
17
31
|
expiresAt: string | null;
|
|
32
|
+
assets?: BundleAsset[];
|
|
18
33
|
};
|
|
19
34
|
|
|
20
35
|
export type InitiateBundleRequest = {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
3
3
|
import { Asset } from 'expo-asset';
|
|
4
|
+
import { unzip } from 'react-native-zip-archive';
|
|
4
5
|
|
|
5
|
-
import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
|
|
6
|
+
import type { Platform as BundlePlatform, Bundle, BundleAsset } from '../../data/apps/bundles/types';
|
|
6
7
|
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
7
8
|
|
|
8
9
|
function sleep(ms: number): Promise<void> {
|
|
@@ -90,6 +91,8 @@ export type UseBundleManagerResult = BundleLoadState & {
|
|
|
90
91
|
export type EmbeddedBaseBundle = {
|
|
91
92
|
module: number;
|
|
92
93
|
meta?: BaseBundleMeta | null;
|
|
94
|
+
assetsModule?: number;
|
|
95
|
+
assetsMeta?: EmbeddedAssetsMeta | null;
|
|
93
96
|
};
|
|
94
97
|
|
|
95
98
|
export type EmbeddedBaseBundles = {
|
|
@@ -97,6 +100,11 @@ export type EmbeddedBaseBundles = {
|
|
|
97
100
|
android?: EmbeddedBaseBundle;
|
|
98
101
|
};
|
|
99
102
|
|
|
103
|
+
type EmbeddedAssetsMeta = {
|
|
104
|
+
checksumSha256: string | null;
|
|
105
|
+
size: number | null;
|
|
106
|
+
};
|
|
107
|
+
|
|
100
108
|
function safeName(s: string) {
|
|
101
109
|
return s.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
102
110
|
}
|
|
@@ -114,6 +122,11 @@ async function ensureDir(path: string) {
|
|
|
114
122
|
await FileSystem.makeDirectoryAsync(path, { intermediates: true });
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
async function ensureBundleDir(key: string) {
|
|
126
|
+
await ensureDir(bundlesCacheDir());
|
|
127
|
+
await ensureDir(bundleDir(key));
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
function baseBundleKey(appId: string, platform: BundlePlatform): string {
|
|
118
131
|
return `base:${appId}:${platform}`;
|
|
119
132
|
}
|
|
@@ -122,16 +135,37 @@ function testBundleKey(appId: string, commitId: string | null | undefined, platf
|
|
|
122
135
|
return `test:${appId}:${commitId ?? 'head'}:${platform}:${bundleId}`;
|
|
123
136
|
}
|
|
124
137
|
|
|
125
|
-
function
|
|
138
|
+
function legacyBundleFileUri(key: string): string {
|
|
126
139
|
const dir = bundlesCacheDir();
|
|
127
140
|
return `${dir}${safeName(key)}.jsbundle`;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
|
-
function
|
|
143
|
+
function legacyBundleMetaFileUri(key: string): string {
|
|
131
144
|
const dir = bundlesCacheDir();
|
|
132
145
|
return `${dir}${safeName(key)}.meta.json`;
|
|
133
146
|
}
|
|
134
147
|
|
|
148
|
+
function bundleDir(key: string): string {
|
|
149
|
+
const dir = bundlesCacheDir();
|
|
150
|
+
return `${dir}${safeName(key)}/`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toBundleFileUri(key: string, platform: BundlePlatform): string {
|
|
154
|
+
return `${bundleDir(key)}index.${platform}.jsbundle`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function toBundleMetaFileUri(key: string): string {
|
|
158
|
+
return `${bundleDir(key)}bundle.meta.json`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toAssetsMetaFileUri(key: string): string {
|
|
162
|
+
return `${bundleDir(key)}assets.meta.json`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function toAssetsDir(key: string): string {
|
|
166
|
+
return `${bundleDir(key)}assets/`;
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
type BaseBundleMeta = {
|
|
136
170
|
fingerprint: string;
|
|
137
171
|
bundleId: string;
|
|
@@ -156,7 +190,7 @@ async function writeJsonFile(fileUri: string, value: unknown): Promise<void> {
|
|
|
156
190
|
try {
|
|
157
191
|
await (FileSystem as any).writeAsStringAsync(fileUri, JSON.stringify(value));
|
|
158
192
|
} catch {
|
|
159
|
-
|
|
193
|
+
|
|
160
194
|
}
|
|
161
195
|
}
|
|
162
196
|
|
|
@@ -170,6 +204,15 @@ async function getExistingNonEmptyFileUri(fileUri: string): Promise<string | nul
|
|
|
170
204
|
}
|
|
171
205
|
}
|
|
172
206
|
|
|
207
|
+
async function getExistingBundleFileUri(key: string, platform: BundlePlatform): Promise<string | null> {
|
|
208
|
+
const nextPath = toBundleFileUri(key, platform);
|
|
209
|
+
const next = await getExistingNonEmptyFileUri(nextPath);
|
|
210
|
+
if (next) return next;
|
|
211
|
+
const legacyPath = legacyBundleFileUri(key);
|
|
212
|
+
const legacy = await getExistingNonEmptyFileUri(legacyPath);
|
|
213
|
+
return legacy;
|
|
214
|
+
}
|
|
215
|
+
|
|
173
216
|
async function downloadIfMissing(url: string, fileUri: string): Promise<string> {
|
|
174
217
|
const existing = await getExistingNonEmptyFileUri(fileUri);
|
|
175
218
|
if (existing) return existing;
|
|
@@ -189,9 +232,9 @@ async function deleteFileIfExists(fileUri: string) {
|
|
|
189
232
|
try {
|
|
190
233
|
const info = await FileSystem.getInfoAsync(fileUri);
|
|
191
234
|
if (!info.exists) return;
|
|
192
|
-
await FileSystem.deleteAsync(fileUri).catch(() => {});
|
|
235
|
+
await FileSystem.deleteAsync(fileUri).catch(() => { });
|
|
193
236
|
} catch {
|
|
194
|
-
|
|
237
|
+
|
|
195
238
|
}
|
|
196
239
|
}
|
|
197
240
|
|
|
@@ -202,9 +245,12 @@ async function hydrateBaseFromEmbeddedAsset(
|
|
|
202
245
|
): Promise<{ bundlePath: string; meta?: BaseBundleMeta | null } | null> {
|
|
203
246
|
if (!embedded?.module) return null;
|
|
204
247
|
const key = baseBundleKey(appId, platform);
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
248
|
+
const existing = await getExistingBundleFileUri(key, platform);
|
|
249
|
+
if (existing) {
|
|
250
|
+
return { bundlePath: existing, meta: embedded.meta ?? null };
|
|
251
|
+
}
|
|
252
|
+
await ensureBundleDir(key);
|
|
253
|
+
const targetUri = toBundleFileUri(key, platform);
|
|
208
254
|
|
|
209
255
|
const asset = Asset.fromModule(embedded.module);
|
|
210
256
|
await asset.downloadAsync();
|
|
@@ -220,8 +266,69 @@ async function hydrateBaseFromEmbeddedAsset(
|
|
|
220
266
|
return { bundlePath: finalUri, meta: embedded.meta ?? null };
|
|
221
267
|
}
|
|
222
268
|
|
|
223
|
-
async function
|
|
224
|
-
|
|
269
|
+
async function hydrateAssetsFromEmbeddedAsset(
|
|
270
|
+
appId: string,
|
|
271
|
+
platform: BundlePlatform,
|
|
272
|
+
key: string,
|
|
273
|
+
embedded: EmbeddedBaseBundle | undefined
|
|
274
|
+
): Promise<boolean> {
|
|
275
|
+
const moduleId = embedded?.assetsModule;
|
|
276
|
+
if (!moduleId) return false;
|
|
277
|
+
|
|
278
|
+
const assetsMeta = embedded?.assetsMeta ?? null;
|
|
279
|
+
const assetsDir = toAssetsDir(key);
|
|
280
|
+
const metaUri = toAssetsMetaFileUri(key);
|
|
281
|
+
|
|
282
|
+
const existingMeta = await readJsonFile<AssetsMeta>(metaUri);
|
|
283
|
+
const assetsDirInfo = await FileSystem.getInfoAsync(assetsDir);
|
|
284
|
+
const assetsDirExists = assetsDirInfo.exists && assetsDirInfo.isDirectory;
|
|
285
|
+
const checksumMatches =
|
|
286
|
+
Boolean(existingMeta?.checksumSha256) &&
|
|
287
|
+
Boolean(assetsMeta?.checksumSha256) &&
|
|
288
|
+
existingMeta?.checksumSha256 === assetsMeta?.checksumSha256;
|
|
289
|
+
const embeddedMetaMatches = existingMeta?.storageKey?.startsWith('embedded:');
|
|
290
|
+
if (assetsDirExists && checksumMatches && embeddedMetaMatches) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await ensureBundleDir(key);
|
|
295
|
+
await ensureDir(assetsDir);
|
|
296
|
+
|
|
297
|
+
const asset = Asset.fromModule(moduleId);
|
|
298
|
+
await asset.downloadAsync();
|
|
299
|
+
const sourceUri = asset.localUri ?? asset.uri;
|
|
300
|
+
if (!sourceUri) return false;
|
|
301
|
+
const info = await FileSystem.getInfoAsync(sourceUri);
|
|
302
|
+
if (!info.exists) return false;
|
|
303
|
+
|
|
304
|
+
const zipUri = `${bundleDir(key)}assets.zip`;
|
|
305
|
+
await deleteFileIfExists(zipUri);
|
|
306
|
+
await FileSystem.copyAsync({ from: sourceUri, to: zipUri });
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await FileSystem.deleteAsync(assetsDir, { idempotent: true }).catch(() => { });
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
await ensureDir(assetsDir);
|
|
313
|
+
await unzipArchive(zipUri, assetsDir);
|
|
314
|
+
|
|
315
|
+
await writeJsonFile(metaUri, {
|
|
316
|
+
checksumSha256: assetsMeta?.checksumSha256 ?? null,
|
|
317
|
+
storageKey: `embedded:${assetsMeta?.checksumSha256 ?? 'unknown'}`,
|
|
318
|
+
updatedAt: new Date().toISOString(),
|
|
319
|
+
} satisfies AssetsMeta);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function safeReplaceFileFromUrl(
|
|
324
|
+
url: string,
|
|
325
|
+
targetUri: string,
|
|
326
|
+
tmpKey: string,
|
|
327
|
+
platform: BundlePlatform
|
|
328
|
+
): Promise<string> {
|
|
329
|
+
const tmpKeySafe = `tmp:${tmpKey}:${Date.now()}`;
|
|
330
|
+
const tmpUri = toBundleFileUri(tmpKeySafe, platform);
|
|
331
|
+
await ensureDir(bundleDir(tmpKeySafe));
|
|
225
332
|
try {
|
|
226
333
|
await withRetry(
|
|
227
334
|
async () => {
|
|
@@ -244,6 +351,107 @@ async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: st
|
|
|
244
351
|
}
|
|
245
352
|
}
|
|
246
353
|
|
|
354
|
+
async function safeReplaceFileFromUrlToPath(url: string, targetUri: string, tmpKey: string): Promise<string> {
|
|
355
|
+
const tmpDir = `${bundlesCacheDir()}tmp/`;
|
|
356
|
+
await ensureDir(tmpDir);
|
|
357
|
+
const tmpUri = `${tmpDir}${safeName(tmpKey)}.tmp`;
|
|
358
|
+
try {
|
|
359
|
+
await withRetry(
|
|
360
|
+
async () => {
|
|
361
|
+
await deleteFileIfExists(tmpUri);
|
|
362
|
+
await FileSystem.downloadAsync(url, tmpUri);
|
|
363
|
+
const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
|
|
364
|
+
if (!tmpOk) throw new Error('Downloaded file is empty.');
|
|
365
|
+
},
|
|
366
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
367
|
+
);
|
|
368
|
+
await deleteFileIfExists(targetUri);
|
|
369
|
+
await FileSystem.moveAsync({ from: tmpUri, to: targetUri });
|
|
370
|
+
const finalOk = await getExistingNonEmptyFileUri(targetUri);
|
|
371
|
+
if (!finalOk) throw new Error('File replacement failed.');
|
|
372
|
+
return targetUri;
|
|
373
|
+
} finally {
|
|
374
|
+
await deleteFileIfExists(tmpUri);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getMetroAssets(bundle: Bundle): BundleAsset | null {
|
|
379
|
+
const assets = bundle.assets ?? [];
|
|
380
|
+
return assets.find((asset) => asset.kind === 'metro-assets') ?? null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
type AssetsMeta = {
|
|
384
|
+
checksumSha256: string | null;
|
|
385
|
+
storageKey: string;
|
|
386
|
+
updatedAt: string;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
async function ensureAssetsForBundle(
|
|
390
|
+
appId: string,
|
|
391
|
+
bundle: Bundle,
|
|
392
|
+
key: string,
|
|
393
|
+
platform: BundlePlatform
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
const asset = getMetroAssets(bundle);
|
|
396
|
+
if (!asset?.storageKey) return;
|
|
397
|
+
|
|
398
|
+
await ensureBundleDir(key);
|
|
399
|
+
const assetsDir = toAssetsDir(key);
|
|
400
|
+
await ensureDir(assetsDir);
|
|
401
|
+
|
|
402
|
+
const metaUri = toAssetsMetaFileUri(key);
|
|
403
|
+
const existingMeta = await readJsonFile<AssetsMeta>(metaUri);
|
|
404
|
+
const assetsDirInfo = await FileSystem.getInfoAsync(assetsDir);
|
|
405
|
+
const assetsDirExists = assetsDirInfo.exists && assetsDirInfo.isDirectory;
|
|
406
|
+
if (
|
|
407
|
+
existingMeta?.checksumSha256 &&
|
|
408
|
+
asset.checksumSha256 &&
|
|
409
|
+
existingMeta.checksumSha256 === asset.checksumSha256 &&
|
|
410
|
+
(existingMeta.storageKey === asset.storageKey || existingMeta.storageKey?.startsWith('embedded:')) &&
|
|
411
|
+
assetsDirExists
|
|
412
|
+
) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const signed = await withRetry(
|
|
417
|
+
async () => {
|
|
418
|
+
return await bundlesRepository.getSignedAssetsDownloadUrl(appId, bundle.id, {
|
|
419
|
+
redirect: false,
|
|
420
|
+
kind: asset.kind,
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const zipUri = `${bundleDir(key)}assets.zip`;
|
|
427
|
+
await safeReplaceFileFromUrlToPath(signed.url, zipUri, `${appId}:${bundle.id}:${platform}:${asset.kind}`);
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
await FileSystem.deleteAsync(assetsDir, { idempotent: true }).catch(() => { });
|
|
431
|
+
} catch {
|
|
432
|
+
|
|
433
|
+
}
|
|
434
|
+
await ensureDir(assetsDir);
|
|
435
|
+
await unzipArchive(zipUri, assetsDir);
|
|
436
|
+
await writeJsonFile(metaUri, {
|
|
437
|
+
checksumSha256: asset.checksumSha256 ?? null,
|
|
438
|
+
storageKey: asset.storageKey,
|
|
439
|
+
updatedAt: new Date().toISOString(),
|
|
440
|
+
} satisfies AssetsMeta);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function unzipArchive(sourceUri: string, destDir: string) {
|
|
444
|
+
try {
|
|
445
|
+
await unzip(sourceUri, destDir);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`Failed to extract assets archive. Ensure 'react-native-zip-archive' is installed in the host app. ${String(
|
|
449
|
+
(e as Error)?.message ?? e
|
|
450
|
+
)}`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
247
455
|
async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: number; intervalMs: number }): Promise<Bundle> {
|
|
248
456
|
const start = Date.now();
|
|
249
457
|
while (true) {
|
|
@@ -291,21 +499,41 @@ async function resolveBundlePath(
|
|
|
291
499
|
throw new Error('Bundle build failed.');
|
|
292
500
|
}
|
|
293
501
|
|
|
502
|
+
let bundleWithAssets = finalBundle;
|
|
503
|
+
if (finalBundle.status === 'succeeded' && (!finalBundle.assets || finalBundle.assets.length === 0)) {
|
|
504
|
+
try {
|
|
505
|
+
bundleWithAssets = await bundlesRepository.getById(appId, finalBundle.id);
|
|
506
|
+
} catch {
|
|
507
|
+
bundleWithAssets = finalBundle;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
294
511
|
const signed = await withRetry(
|
|
295
512
|
async () => {
|
|
296
513
|
return await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
|
|
297
514
|
},
|
|
298
515
|
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
299
516
|
);
|
|
517
|
+
const key =
|
|
518
|
+
mode === 'base'
|
|
519
|
+
? baseBundleKey(appId, platform)
|
|
520
|
+
: testBundleKey(appId, commitId, platform, finalBundle.id);
|
|
521
|
+
await ensureBundleDir(key);
|
|
300
522
|
const bundlePath =
|
|
301
523
|
mode === 'base'
|
|
302
524
|
? await safeReplaceFileFromUrl(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
525
|
+
signed.url,
|
|
526
|
+
toBundleFileUri(key, platform),
|
|
527
|
+
`${appId}:${commitId ?? 'head'}:${platform}:${finalBundle.id}`,
|
|
528
|
+
platform
|
|
529
|
+
)
|
|
530
|
+
: await downloadIfMissing(signed.url, toBundleFileUri(key, platform));
|
|
531
|
+
try {
|
|
532
|
+
await ensureAssetsForBundle(appId, bundleWithAssets, key, platform);
|
|
533
|
+
} catch {
|
|
534
|
+
|
|
535
|
+
}
|
|
536
|
+
return { bundlePath, label: 'Ready', bundle: bundleWithAssets };
|
|
309
537
|
}
|
|
310
538
|
|
|
311
539
|
export function useBundleManager({
|
|
@@ -360,8 +588,7 @@ export function useBundleManager({
|
|
|
360
588
|
const dir = bundlesCacheDir();
|
|
361
589
|
await ensureDir(dir);
|
|
362
590
|
const key = baseBundleKey(appId, platform);
|
|
363
|
-
|
|
364
|
-
let existing = await getExistingNonEmptyFileUri(uri);
|
|
591
|
+
let existing = await getExistingBundleFileUri(key, platform);
|
|
365
592
|
let embeddedMeta: BaseBundleMeta | null = null;
|
|
366
593
|
if (!existing) {
|
|
367
594
|
const embedded = embeddedBaseBundlesRef.current?.[platform];
|
|
@@ -370,14 +597,29 @@ export function useBundleManager({
|
|
|
370
597
|
existing = hydrated.bundlePath;
|
|
371
598
|
embeddedMeta = hydrated.meta ?? null;
|
|
372
599
|
if (embeddedMeta) {
|
|
600
|
+
await ensureBundleDir(key);
|
|
373
601
|
await writeJsonFile(toBundleMetaFileUri(key), embeddedMeta);
|
|
602
|
+
await writeJsonFile(legacyBundleMetaFileUri(key), embeddedMeta);
|
|
374
603
|
}
|
|
375
604
|
}
|
|
376
605
|
}
|
|
377
606
|
if (existing) {
|
|
378
607
|
lastBaseBundlePathRef.current = existing;
|
|
379
608
|
setBundlePath(existing);
|
|
380
|
-
const meta =
|
|
609
|
+
const meta =
|
|
610
|
+
embeddedMeta ??
|
|
611
|
+
(await readJsonFile<BaseBundleMeta>(toBundleMetaFileUri(key))) ??
|
|
612
|
+
(await readJsonFile<BaseBundleMeta>(legacyBundleMetaFileUri(key)));
|
|
613
|
+
const embedded = embeddedBaseBundlesRef.current?.[platform];
|
|
614
|
+
const embeddedFingerprint = embedded?.meta?.fingerprint ?? null;
|
|
615
|
+
const actualFingerprint = meta?.fingerprint ?? embeddedMeta?.fingerprint ?? null;
|
|
616
|
+
if (embedded?.assetsModule && embeddedFingerprint && actualFingerprint === embeddedFingerprint) {
|
|
617
|
+
try {
|
|
618
|
+
await hydrateAssetsFromEmbeddedAsset(appId, platform, key, embedded);
|
|
619
|
+
} catch {
|
|
620
|
+
|
|
621
|
+
}
|
|
622
|
+
}
|
|
381
623
|
if (meta?.fingerprint) {
|
|
382
624
|
lastBaseFingerprintRef.current = meta.fingerprint;
|
|
383
625
|
}
|
|
@@ -387,7 +629,7 @@ export function useBundleManager({
|
|
|
387
629
|
}
|
|
388
630
|
}
|
|
389
631
|
} catch {
|
|
390
|
-
|
|
632
|
+
|
|
391
633
|
}
|
|
392
634
|
},
|
|
393
635
|
[platform]
|
|
@@ -461,7 +703,16 @@ export function useBundleManager({
|
|
|
461
703
|
lastBaseFingerprintRef.current = fingerprint;
|
|
462
704
|
hasCompletedFirstNetworkBaseLoadRef.current = true;
|
|
463
705
|
initialHydratedBaseFromDiskRef.current = false;
|
|
464
|
-
|
|
706
|
+
const metaKey = baseBundleKey(src.appId, platform);
|
|
707
|
+
await ensureBundleDir(metaKey);
|
|
708
|
+
void writeJsonFile(toBundleMetaFileUri(metaKey), {
|
|
709
|
+
fingerprint,
|
|
710
|
+
bundleId: bundle.id,
|
|
711
|
+
checksumSha256: bundle.checksumSha256 ?? null,
|
|
712
|
+
size: bundle.size ?? null,
|
|
713
|
+
updatedAt: new Date().toISOString(),
|
|
714
|
+
} satisfies BaseBundleMeta);
|
|
715
|
+
void writeJsonFile(legacyBundleMetaFileUri(metaKey), {
|
|
465
716
|
fingerprint,
|
|
466
717
|
bundleId: bundle.id,
|
|
467
718
|
checksumSha256: bundle.checksumSha256 ?? null,
|