@comergehq/studio 0.1.8 → 0.1.10
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.js +642 -427
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +363 -148
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatMessageList.tsx +23 -35
- package/src/components/chat/ChatPage.tsx +26 -30
- package/src/components/studio-sheet/StudioBottomSheet.tsx +4 -19
- package/src/studio/ComergeStudio.tsx +34 -1
- package/src/studio/hooks/useAttachmentUpload.ts +51 -5
- package/src/studio/hooks/useBundleManager.ts +91 -17
- package/src/studio/hooks/useOptimisticChatMessages.ts +128 -0
- package/src/studio/ui/ChatPanel.tsx +1 -0
- package/src/studio/ui/RuntimeRenderer.tsx +7 -2
- package/src/studio/ui/StudioOverlay.tsx +19 -6
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { View, type NativeScrollEvent, type NativeSyntheticEvent, type ViewStyle } from 'react-native';
|
|
2
|
+
import { Platform, View, type NativeScrollEvent, type NativeSyntheticEvent, type ViewStyle } from 'react-native';
|
|
3
3
|
import { BottomSheetFlatList } from '@gorhom/bottom-sheet';
|
|
4
4
|
|
|
5
5
|
import type { ChatMessage } from '../models/types';
|
|
@@ -46,10 +46,13 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
46
46
|
const initialScrollDoneRef = React.useRef(false);
|
|
47
47
|
const lastMessageIdRef = React.useRef<string | null>(null);
|
|
48
48
|
|
|
49
|
+
const data = React.useMemo(() => {
|
|
50
|
+
return [...messages].reverse();
|
|
51
|
+
}, [messages]);
|
|
52
|
+
|
|
49
53
|
const scrollToBottom = React.useCallback((options?: { animated?: boolean }) => {
|
|
50
54
|
const animated = options?.animated ?? true;
|
|
51
|
-
|
|
52
|
-
listRef.current?.scrollToEnd({ animated });
|
|
55
|
+
listRef.current?.scrollToOffset({ offset: 0, animated });
|
|
53
56
|
}, []);
|
|
54
57
|
|
|
55
58
|
React.useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);
|
|
@@ -57,12 +60,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
57
60
|
const handleScroll = React.useCallback(
|
|
58
61
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
59
62
|
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
|
60
|
-
|
|
61
|
-
// so "near bottom" still means "near the latest message", not "deep into empty space".
|
|
62
|
-
const distanceFromBottom = Math.max(
|
|
63
|
-
contentSize.height - Math.max(bottomInset, 0) - (contentOffset.y + layoutMeasurement.height),
|
|
64
|
-
0
|
|
65
|
-
);
|
|
63
|
+
const distanceFromBottom = Math.max(contentOffset.y - Math.max(bottomInset, 0), 0);
|
|
66
64
|
const isNear = distanceFromBottom <= nearBottomThreshold;
|
|
67
65
|
|
|
68
66
|
if (nearBottomRef.current !== isNear) {
|
|
@@ -73,16 +71,6 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
73
71
|
[bottomInset, nearBottomThreshold, onNearBottomChange]
|
|
74
72
|
);
|
|
75
73
|
|
|
76
|
-
// On first load, start at the bottom
|
|
77
|
-
React.useEffect(() => {
|
|
78
|
-
if (initialScrollDoneRef.current) return;
|
|
79
|
-
if (messages.length === 0) return;
|
|
80
|
-
|
|
81
|
-
initialScrollDoneRef.current = true;
|
|
82
|
-
lastMessageIdRef.current = messages[messages.length - 1]?.id ?? null;
|
|
83
|
-
const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
84
|
-
return () => cancelAnimationFrame(id);
|
|
85
|
-
}, [messages, scrollToBottom]);
|
|
86
74
|
|
|
87
75
|
// When new messages arrive, keep the user pinned to the bottom only if they already were near it.
|
|
88
76
|
React.useEffect(() => {
|
|
@@ -106,36 +94,36 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
106
94
|
return undefined;
|
|
107
95
|
}, [showTypingIndicator, scrollToBottom]);
|
|
108
96
|
|
|
109
|
-
// When the bottom inset grows/shrinks (e.g. composer height changes), keep pinned users at bottom.
|
|
110
|
-
React.useEffect(() => {
|
|
111
|
-
if (!initialScrollDoneRef.current) return;
|
|
112
|
-
if (!nearBottomRef.current) return;
|
|
113
|
-
const id = requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
114
|
-
return () => cancelAnimationFrame(id);
|
|
115
|
-
}, [bottomInset, scrollToBottom]);
|
|
116
|
-
|
|
117
97
|
return (
|
|
118
98
|
<BottomSheetFlatList
|
|
119
99
|
ref={listRef}
|
|
120
|
-
|
|
100
|
+
inverted
|
|
101
|
+
data={data}
|
|
121
102
|
keyExtractor={(m: ChatMessage) => m.id}
|
|
103
|
+
keyboardShouldPersistTaps="handled"
|
|
122
104
|
onScroll={handleScroll}
|
|
123
105
|
scrollEventThrottle={16}
|
|
124
106
|
showsVerticalScrollIndicator={false}
|
|
107
|
+
onContentSizeChange={() => {
|
|
108
|
+
if (initialScrollDoneRef.current) return;
|
|
109
|
+
initialScrollDoneRef.current = true;
|
|
110
|
+
lastMessageIdRef.current = messages.length > 0 ? messages[messages.length - 1]!.id : null;
|
|
111
|
+
nearBottomRef.current = true;
|
|
112
|
+
onNearBottomChange?.(true);
|
|
113
|
+
requestAnimationFrame(() => scrollToBottom({ animated: false }));
|
|
114
|
+
}}
|
|
125
115
|
contentContainerStyle={[
|
|
126
116
|
{
|
|
127
117
|
paddingHorizontal: theme.spacing.lg,
|
|
128
|
-
|
|
129
|
-
paddingBottom: theme.spacing.sm,
|
|
118
|
+
paddingVertical: theme.spacing.sm,
|
|
130
119
|
},
|
|
131
120
|
contentStyle,
|
|
132
121
|
]}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
</View>
|
|
122
|
+
ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
|
|
123
|
+
renderItem={({ item }: { item: ChatMessage }) => (
|
|
124
|
+
<ChatMessageBubble message={item} renderContent={renderMessageContent} />
|
|
137
125
|
)}
|
|
138
|
-
|
|
126
|
+
ListHeaderComponent={
|
|
139
127
|
<View>
|
|
140
128
|
{showTypingIndicator ? (
|
|
141
129
|
<View style={{ marginTop: theme.spacing.sm, alignSelf: 'flex-start', paddingHorizontal: theme.spacing.lg }}>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { Keyboard, Platform, View, type ViewStyle } from 'react-native';
|
|
3
3
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
4
|
-
import Animated, { useAnimatedKeyboard, useAnimatedStyle } from 'react-native-reanimated';
|
|
5
4
|
|
|
6
5
|
import type { ChatMessage } from '../models/types';
|
|
7
6
|
import { useTheme } from '../../theme';
|
|
@@ -22,6 +21,7 @@ export type ChatPageProps = {
|
|
|
22
21
|
*/
|
|
23
22
|
overlay?: React.ReactNode;
|
|
24
23
|
style?: ViewStyle;
|
|
24
|
+
composerHorizontalPadding?: number;
|
|
25
25
|
onNearBottomChange?: ChatMessageListProps['onNearBottomChange'];
|
|
26
26
|
listRef?: React.RefObject<ChatMessageListRef | null>;
|
|
27
27
|
};
|
|
@@ -35,6 +35,7 @@ export function ChatPage({
|
|
|
35
35
|
composer,
|
|
36
36
|
overlay,
|
|
37
37
|
style,
|
|
38
|
+
composerHorizontalPadding,
|
|
38
39
|
onNearBottomChange,
|
|
39
40
|
listRef,
|
|
40
41
|
}: ChatPageProps) {
|
|
@@ -42,11 +43,9 @@ export function ChatPage({
|
|
|
42
43
|
const insets = useSafeAreaInsets();
|
|
43
44
|
const [composerHeight, setComposerHeight] = React.useState(0);
|
|
44
45
|
const [keyboardVisible, setKeyboardVisible] = React.useState(false);
|
|
45
|
-
const animatedKeyboard = useAnimatedKeyboard();
|
|
46
46
|
|
|
47
47
|
React.useEffect(() => {
|
|
48
48
|
if (Platform.OS !== 'ios') return;
|
|
49
|
-
|
|
50
49
|
const show = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
|
|
51
50
|
const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardVisible(false));
|
|
52
51
|
return () => {
|
|
@@ -56,10 +55,6 @@ export function ChatPage({
|
|
|
56
55
|
}, []);
|
|
57
56
|
|
|
58
57
|
const footerBottomPadding = Platform.OS === 'ios' ? (keyboardVisible ? 0 : insets.bottom) : insets.bottom + 10;
|
|
59
|
-
const footerAnimatedStyle = useAnimatedStyle(() => {
|
|
60
|
-
if (Platform.OS !== 'ios') return { paddingBottom: insets.bottom + 10 };
|
|
61
|
-
return { paddingBottom: animatedKeyboard.height.value > 0 ? 0 : insets.bottom };
|
|
62
|
-
});
|
|
63
58
|
const overlayBottom = composerHeight + footerBottomPadding + theme.spacing.lg;
|
|
64
59
|
const bottomInset = composerHeight + footerBottomPadding + theme.spacing.xl;
|
|
65
60
|
|
|
@@ -80,35 +75,36 @@ export function ChatPage({
|
|
|
80
75
|
</View>
|
|
81
76
|
) : null}
|
|
82
77
|
<View style={{ flex: 1 }}>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
78
|
+
<View
|
|
79
|
+
style={{ flex: 1 }}
|
|
80
|
+
>
|
|
81
|
+
<ChatMessageList
|
|
82
|
+
ref={listRef}
|
|
83
|
+
messages={messages}
|
|
84
|
+
showTypingIndicator={showTypingIndicator}
|
|
85
|
+
renderMessageContent={renderMessageContent}
|
|
86
|
+
onNearBottomChange={onNearBottomChange}
|
|
87
|
+
bottomInset={bottomInset}
|
|
88
|
+
/>
|
|
89
|
+
{resolvedOverlay}
|
|
90
|
+
</View>
|
|
91
|
+
<View
|
|
92
|
+
style={{
|
|
93
|
+
position: 'absolute',
|
|
94
|
+
left: 0,
|
|
95
|
+
right: 0,
|
|
96
|
+
bottom: 0,
|
|
97
|
+
paddingHorizontal: composerHorizontalPadding ?? theme.spacing.md,
|
|
98
|
+
paddingTop: theme.spacing.sm,
|
|
99
|
+
paddingBottom: footerBottomPadding,
|
|
100
|
+
}}
|
|
105
101
|
>
|
|
106
102
|
<ChatComposer
|
|
107
103
|
{...composer}
|
|
108
104
|
attachments={composer.attachments ?? []}
|
|
109
105
|
onLayout={({ height }) => setComposerHeight(height)}
|
|
110
106
|
/>
|
|
111
|
-
</
|
|
107
|
+
</View>
|
|
112
108
|
</View>
|
|
113
109
|
</View>
|
|
114
110
|
);
|
|
@@ -35,8 +35,7 @@ export type StudioBottomSheetProps = {
|
|
|
35
35
|
children: React.ReactNode;
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Additional BottomSheet props
|
|
39
|
-
* We intentionally do not expose everything as first-class props to keep SRP.
|
|
38
|
+
* Additional BottomSheet props
|
|
40
39
|
*/
|
|
41
40
|
bottomSheetProps?: Omit<
|
|
42
41
|
BottomSheetProps,
|
|
@@ -56,7 +55,7 @@ export type StudioBottomSheetProps = {
|
|
|
56
55
|
export function StudioBottomSheet({
|
|
57
56
|
open,
|
|
58
57
|
onOpenChange,
|
|
59
|
-
snapPoints = ['
|
|
58
|
+
snapPoints = ['100%'],
|
|
60
59
|
sheetRef,
|
|
61
60
|
background,
|
|
62
61
|
children,
|
|
@@ -93,20 +92,6 @@ export function StudioBottomSheet({
|
|
|
93
92
|
return () => sub.remove();
|
|
94
93
|
}, [open, resolvedSheetRef]);
|
|
95
94
|
|
|
96
|
-
React.useEffect(() => {
|
|
97
|
-
if (Platform.OS !== 'ios') return;
|
|
98
|
-
const sub = Keyboard.addListener('keyboardDidHide', () => {
|
|
99
|
-
const sheet = resolvedSheetRef.current;
|
|
100
|
-
if (!sheet || !open) return;
|
|
101
|
-
const targetIndex = snapPoints.length - 1;
|
|
102
|
-
// Only "re-snap" if we're already at the highest snap point.
|
|
103
|
-
if (currentIndexRef.current === targetIndex) {
|
|
104
|
-
setTimeout(() => sheet.snapToIndex(targetIndex), 10);
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
return () => sub.remove();
|
|
108
|
-
}, [open, resolvedSheetRef, snapPoints.length]);
|
|
109
|
-
|
|
110
95
|
React.useEffect(() => {
|
|
111
96
|
const sheet = resolvedSheetRef.current;
|
|
112
97
|
if (!sheet) return;
|
|
@@ -132,9 +117,9 @@ export function StudioBottomSheet({
|
|
|
132
117
|
ref={resolvedSheetRef}
|
|
133
118
|
index={open ? snapPoints.length - 1 : -1}
|
|
134
119
|
snapPoints={snapPoints}
|
|
120
|
+
enableDynamicSizing={false}
|
|
135
121
|
enablePanDownToClose
|
|
136
|
-
|
|
137
|
-
keyboardBlurBehavior="restore"
|
|
122
|
+
enableContentPanningGesture={false}
|
|
138
123
|
android_keyboardInputMode="adjustResize"
|
|
139
124
|
backgroundComponent={(props: BottomSheetBackgroundProps) => (
|
|
140
125
|
<StudioSheetBackground {...props} renderBackground={background?.renderBackground} />
|
|
@@ -125,6 +125,34 @@ function ComergeStudioInner({
|
|
|
125
125
|
canRequestLatest: runtimeApp?.status === 'ready',
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
const sawEditingOnActiveAppRef = React.useRef(false);
|
|
129
|
+
const [showPostEditPreparing, setShowPostEditPreparing] = React.useState(false);
|
|
130
|
+
React.useEffect(() => {
|
|
131
|
+
sawEditingOnActiveAppRef.current = false;
|
|
132
|
+
setShowPostEditPreparing(false);
|
|
133
|
+
}, [activeAppId]);
|
|
134
|
+
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
if (!app?.id) return;
|
|
137
|
+
if (app.status === 'editing') {
|
|
138
|
+
sawEditingOnActiveAppRef.current = true;
|
|
139
|
+
setShowPostEditPreparing(false);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (app.status === 'ready' && sawEditingOnActiveAppRef.current) {
|
|
143
|
+
setShowPostEditPreparing(true);
|
|
144
|
+
sawEditingOnActiveAppRef.current = false;
|
|
145
|
+
}
|
|
146
|
+
}, [app?.id, app?.status]);
|
|
147
|
+
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
if (!showPostEditPreparing) return;
|
|
150
|
+
const stillProcessingBaseBundle = bundle.loading && bundle.loadingMode === 'base' && !bundle.isTesting;
|
|
151
|
+
if (!stillProcessingBaseBundle) {
|
|
152
|
+
setShowPostEditPreparing(false);
|
|
153
|
+
}
|
|
154
|
+
}, [showPostEditPreparing, bundle.loading, bundle.loadingMode, bundle.isTesting]);
|
|
155
|
+
|
|
128
156
|
const threadId = app?.threadId ?? '';
|
|
129
157
|
const thread = useThreadMessages(threadId);
|
|
130
158
|
|
|
@@ -173,7 +201,12 @@ function ComergeStudioInner({
|
|
|
173
201
|
return (
|
|
174
202
|
<View style={[{ flex: 1 }, style]}>
|
|
175
203
|
<View ref={captureTargetRef} style={{ flex: 1 }} collapsable={false}>
|
|
176
|
-
<RuntimeRenderer
|
|
204
|
+
<RuntimeRenderer
|
|
205
|
+
appKey={appKey}
|
|
206
|
+
bundlePath={bundle.bundlePath}
|
|
207
|
+
forcePreparing={showPostEditPreparing}
|
|
208
|
+
renderToken={bundle.renderToken}
|
|
209
|
+
/>
|
|
177
210
|
|
|
178
211
|
<StudioOverlay
|
|
179
212
|
captureTargetRef={captureTargetRef}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
import * as FileSystem from 'expo-file-system/legacy';
|
|
2
4
|
|
|
3
5
|
import { attachmentRepository } from '../../data/attachment/repository';
|
|
4
6
|
import type { AttachmentMeta } from '../../data/attachment/types';
|
|
@@ -15,6 +17,47 @@ export type UseAttachmentUploadResult = {
|
|
|
15
17
|
error: Error | null;
|
|
16
18
|
};
|
|
17
19
|
|
|
20
|
+
async function dataUrlToBlobAndroid(dataUrl: string): Promise<Blob> {
|
|
21
|
+
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
22
|
+
const comma = normalized.indexOf(',');
|
|
23
|
+
if (comma === -1) {
|
|
24
|
+
throw new Error('Invalid data URL (missing comma separator)');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const header = normalized.slice(0, comma);
|
|
28
|
+
const base64 = normalized.slice(comma + 1);
|
|
29
|
+
|
|
30
|
+
const mimeMatch = header.match(/data:(.*?);base64/i);
|
|
31
|
+
const mimeType = mimeMatch?.[1] ?? 'application/octet-stream';
|
|
32
|
+
|
|
33
|
+
const cacheDir = FileSystem.cacheDirectory;
|
|
34
|
+
if (!cacheDir) {
|
|
35
|
+
throw new Error('expo-file-system cacheDirectory is unavailable');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fileUri = `${cacheDir}attachment-${Date.now()}-${Math.random().toString(16).slice(2)}.bin`;
|
|
39
|
+
|
|
40
|
+
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
|
41
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const resp = await fetch(fileUri);
|
|
46
|
+
const blob = await resp.blob();
|
|
47
|
+
return blob.type ? blob : new Blob([blob], { type: mimeType });
|
|
48
|
+
} finally {
|
|
49
|
+
void FileSystem.deleteAsync(fileUri, { idempotent: true }).catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getMimeTypeFromDataUrl(dataUrl: string): string {
|
|
54
|
+
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
55
|
+
const comma = normalized.indexOf(',');
|
|
56
|
+
const header = comma === -1 ? normalized : normalized.slice(0, comma);
|
|
57
|
+
const mimeMatch = header.match(/data:(.*?);base64/i);
|
|
58
|
+
return mimeMatch?.[1] ?? 'image/png';
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
19
62
|
const [uploading, setUploading] = React.useState(false);
|
|
20
63
|
const [error, setError] = React.useState<Error | null>(null);
|
|
@@ -29,16 +72,19 @@ export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
|
29
72
|
const blobs = await Promise.all(
|
|
30
73
|
dataUrls.map(async (dataUrl, idx) => {
|
|
31
74
|
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
75
|
+
const blob =
|
|
76
|
+
Platform.OS === 'android'
|
|
77
|
+
? await dataUrlToBlobAndroid(normalized)
|
|
78
|
+
: await (await fetch(normalized)).blob();
|
|
79
|
+
const mimeType = getMimeTypeFromDataUrl(normalized);
|
|
80
|
+
return { blob, idx, mimeType };
|
|
35
81
|
})
|
|
36
82
|
);
|
|
37
83
|
|
|
38
|
-
const files = blobs.map(({ blob }, idx) => ({
|
|
84
|
+
const files = blobs.map(({ blob, mimeType }, idx) => ({
|
|
39
85
|
name: `attachment-${Date.now()}-${idx}.png`,
|
|
40
86
|
size: blob.size,
|
|
41
|
-
mimeType
|
|
87
|
+
mimeType,
|
|
42
88
|
}));
|
|
43
89
|
|
|
44
90
|
const presign = await attachmentRepository.presign({ threadId, appId, files });
|
|
@@ -4,6 +4,47 @@ import * as FileSystem from 'expo-file-system/legacy';
|
|
|
4
4
|
import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
|
|
5
5
|
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
6
6
|
|
|
7
|
+
function sleep(ms: number): Promise<void> {
|
|
8
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isRetryableNetworkError(e: unknown): boolean {
|
|
12
|
+
const err = e as any;
|
|
13
|
+
const code = typeof err?.code === 'string' ? err.code : '';
|
|
14
|
+
const message = typeof err?.message === 'string' ? err.message : '';
|
|
15
|
+
|
|
16
|
+
if (code === 'ERR_NETWORK' || code === 'ECONNABORTED') return true;
|
|
17
|
+
if (message.toLowerCase().includes('network error')) return true;
|
|
18
|
+
if (message.toLowerCase().includes('timeout')) return true;
|
|
19
|
+
|
|
20
|
+
const status = typeof err?.response?.status === 'number' ? err.response.status : undefined;
|
|
21
|
+
if (status && (status === 429 || status >= 500)) return true;
|
|
22
|
+
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function withRetry<T>(
|
|
27
|
+
fn: () => Promise<T>,
|
|
28
|
+
opts: { attempts: number; baseDelayMs: number; maxDelayMs: number }
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
let lastErr: unknown = null;
|
|
31
|
+
for (let attempt = 1; attempt <= opts.attempts; attempt += 1) {
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
} catch (e) {
|
|
35
|
+
lastErr = e;
|
|
36
|
+
const retryable = isRetryableNetworkError(e);
|
|
37
|
+
if (!retryable || attempt >= opts.attempts) {
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
const exp = Math.min(opts.maxDelayMs, opts.baseDelayMs * Math.pow(2, attempt - 1));
|
|
41
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
42
|
+
await sleep(exp + jitter);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw lastErr;
|
|
46
|
+
}
|
|
47
|
+
|
|
7
48
|
type BundleSource = {
|
|
8
49
|
appId: string;
|
|
9
50
|
commitId?: string | null;
|
|
@@ -29,6 +70,7 @@ export type BundleLoadState = {
|
|
|
29
70
|
*/
|
|
30
71
|
renderToken: number;
|
|
31
72
|
loading: boolean;
|
|
73
|
+
loadingMode: 'base' | 'test' | null;
|
|
32
74
|
statusLabel: string | null;
|
|
33
75
|
error: string | null;
|
|
34
76
|
/**
|
|
@@ -119,8 +161,16 @@ async function getExistingNonEmptyFileUri(fileUri: string): Promise<string | nul
|
|
|
119
161
|
async function downloadIfMissing(url: string, fileUri: string): Promise<string> {
|
|
120
162
|
const existing = await getExistingNonEmptyFileUri(fileUri);
|
|
121
163
|
if (existing) return existing;
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
return await withRetry(
|
|
165
|
+
async () => {
|
|
166
|
+
await deleteFileIfExists(fileUri);
|
|
167
|
+
const res = await FileSystem.downloadAsync(url, fileUri);
|
|
168
|
+
const ok = await getExistingNonEmptyFileUri(res.uri);
|
|
169
|
+
if (!ok) throw new Error('Downloaded bundle is empty.');
|
|
170
|
+
return res.uri;
|
|
171
|
+
},
|
|
172
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
173
|
+
);
|
|
124
174
|
}
|
|
125
175
|
|
|
126
176
|
async function deleteFileIfExists(fileUri: string) {
|
|
@@ -136,11 +186,15 @@ async function deleteFileIfExists(fileUri: string) {
|
|
|
136
186
|
async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: string): Promise<string> {
|
|
137
187
|
const tmpUri = toBundleFileUri(`tmp:${tmpKey}:${Date.now()}`);
|
|
138
188
|
try {
|
|
139
|
-
await
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
189
|
+
await withRetry(
|
|
190
|
+
async () => {
|
|
191
|
+
await deleteFileIfExists(tmpUri);
|
|
192
|
+
await FileSystem.downloadAsync(url, tmpUri);
|
|
193
|
+
const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
|
|
194
|
+
if (!tmpOk) throw new Error('Downloaded bundle is empty.');
|
|
195
|
+
},
|
|
196
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
197
|
+
);
|
|
144
198
|
|
|
145
199
|
await deleteFileIfExists(targetUri);
|
|
146
200
|
await FileSystem.moveAsync({ from: tmpUri, to: targetUri });
|
|
@@ -156,12 +210,18 @@ async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: st
|
|
|
156
210
|
async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: number; intervalMs: number }): Promise<Bundle> {
|
|
157
211
|
const start = Date.now();
|
|
158
212
|
while (true) {
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
try {
|
|
214
|
+
const bundle = await bundlesRepository.getById(appId, bundleId);
|
|
215
|
+
if (bundle.status === 'succeeded' || bundle.status === 'failed') return bundle;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (!isRetryableNetworkError(e)) {
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
161
221
|
if (Date.now() - start > opts.timeoutMs) {
|
|
162
222
|
throw new Error('Bundle build timed out.');
|
|
163
223
|
}
|
|
164
|
-
await
|
|
224
|
+
await sleep(opts.intervalMs);
|
|
165
225
|
}
|
|
166
226
|
}
|
|
167
227
|
|
|
@@ -174,11 +234,16 @@ async function resolveBundlePath(
|
|
|
174
234
|
const dir = bundlesCacheDir();
|
|
175
235
|
await ensureDir(dir);
|
|
176
236
|
|
|
177
|
-
const initiate = await
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
237
|
+
const initiate = await withRetry(
|
|
238
|
+
async () => {
|
|
239
|
+
return await bundlesRepository.initiate(appId, {
|
|
240
|
+
platform,
|
|
241
|
+
commitId: commitId ?? undefined,
|
|
242
|
+
idempotencyKey: `${appId}:${commitId ?? 'head'}:${platform}`,
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
246
|
+
);
|
|
182
247
|
|
|
183
248
|
const finalBundle =
|
|
184
249
|
initiate.status === 'succeeded' || initiate.status === 'failed'
|
|
@@ -189,7 +254,12 @@ async function resolveBundlePath(
|
|
|
189
254
|
throw new Error('Bundle build failed.');
|
|
190
255
|
}
|
|
191
256
|
|
|
192
|
-
const signed = await
|
|
257
|
+
const signed = await withRetry(
|
|
258
|
+
async () => {
|
|
259
|
+
return await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
|
|
260
|
+
},
|
|
261
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4000 }
|
|
262
|
+
);
|
|
193
263
|
const bundlePath =
|
|
194
264
|
mode === 'base'
|
|
195
265
|
? await safeReplaceFileFromUrl(
|
|
@@ -209,6 +279,7 @@ export function useBundleManager({
|
|
|
209
279
|
const [bundlePath, setBundlePath] = React.useState<string | null>(null);
|
|
210
280
|
const [renderToken, setRenderToken] = React.useState(0);
|
|
211
281
|
const [loading, setLoading] = React.useState(false);
|
|
282
|
+
const [loadingMode, setLoadingMode] = React.useState<'base' | 'test' | null>(null);
|
|
212
283
|
const [statusLabel, setStatusLabel] = React.useState<string | null>(null);
|
|
213
284
|
const [error, setError] = React.useState<string | null>(null);
|
|
214
285
|
const [isTesting, setIsTesting] = React.useState(false);
|
|
@@ -229,6 +300,7 @@ export function useBundleManager({
|
|
|
229
300
|
baseOpIdRef.current += 1;
|
|
230
301
|
if (activeLoadModeRef.current === 'base') {
|
|
231
302
|
setLoading(false);
|
|
303
|
+
setLoadingMode(null);
|
|
232
304
|
setStatusLabel(null);
|
|
233
305
|
activeLoadModeRef.current = null;
|
|
234
306
|
}
|
|
@@ -303,6 +375,7 @@ export function useBundleManager({
|
|
|
303
375
|
const opId = mode === 'base' ? ++baseOpIdRef.current : ++testOpIdRef.current;
|
|
304
376
|
activeLoadModeRef.current = mode;
|
|
305
377
|
setLoading(true);
|
|
378
|
+
setLoadingMode(mode);
|
|
306
379
|
setError(null);
|
|
307
380
|
setStatusLabel(mode === 'test' ? 'Loading test bundle…' : 'Loading latest build…');
|
|
308
381
|
|
|
@@ -357,6 +430,7 @@ export function useBundleManager({
|
|
|
357
430
|
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
358
431
|
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
359
432
|
setLoading(false);
|
|
433
|
+
setLoadingMode(null);
|
|
360
434
|
if (activeLoadModeRef.current === mode) activeLoadModeRef.current = null;
|
|
361
435
|
}
|
|
362
436
|
}, [activateCachedBase, platform]);
|
|
@@ -383,7 +457,7 @@ export function useBundleManager({
|
|
|
383
457
|
void loadBase();
|
|
384
458
|
}, [base.appId, base.commitId, platform, canRequestLatest, loadBase]);
|
|
385
459
|
|
|
386
|
-
return { bundlePath, renderToken, loading, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
|
|
460
|
+
return { bundlePath, renderToken, loading, loadingMode, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
|
|
387
461
|
}
|
|
388
462
|
|
|
389
463
|
|