Skip to content

Commit 8e005a3

Browse files
committed
fix(ecosystem): prioritize source fetch errors over cache success
chore(vite): update om-hub remote config and ignore generated folder
1 parent e873a94 commit 8e005a3

5 files changed

Lines changed: 112 additions & 50 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Thumbs.db
6868

6969
# Remote miniapps (downloaded at dev/build time)
7070
miniapps/rwa-hub/
71+
miniapps/om-hub/
7172

7273
# Playwright
7374
e2e/test-results/

src/services/ecosystem/__tests__/registry.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,51 @@ describe('Miniapp Registry (Subscription v2)', () => {
6767
expect(getAppById('xin.dweb.teleport')?.name).toBe('Teleport')
6868
})
6969

70+
71+
it('marks source as error when refresh falls back to cache after fetch failure', async () => {
72+
const mockApps = [
73+
{
74+
id: 'xin.dweb.teleport',
75+
name: 'Teleport',
76+
url: '/miniapps/teleport/',
77+
icon: '/miniapps/teleport/icon.svg',
78+
description: 'Test',
79+
version: '1.0.0',
80+
},
81+
]
82+
83+
const fetchMock = vi
84+
.fn<
85+
[RequestInfo | URL],
86+
Promise<{
87+
ok: boolean;
88+
status: number;
89+
headers: Map<string, string>;
90+
json: () => Promise<unknown>;
91+
}>
92+
>()
93+
.mockResolvedValueOnce({
94+
ok: true,
95+
status: 200,
96+
headers: new Map([['ETag', '"v1"']]),
97+
json: () => Promise.resolve({ name: 'x', version: '1', updated: '2025-01-01', apps: mockApps }),
98+
})
99+
.mockRejectedValueOnce(new Error('network down'))
100+
101+
global.fetch = fetchMock as unknown as typeof fetch
102+
103+
await refreshSources({ force: true })
104+
105+
expect(ecosystemStore.state.sources[0]?.status).toBe('success')
106+
107+
const apps = await refreshSources({ force: true })
108+
109+
expect(apps).toHaveLength(1)
110+
expect(getApps()).toHaveLength(1)
111+
expect(ecosystemStore.state.sources[0]?.status).toBe('error')
112+
expect(ecosystemStore.state.sources[0]?.errorMessage).toBe('network down')
113+
})
114+
70115
it('prefers the later source when duplicate app id exists', async () => {
71116
ecosystemStore.setState(() => ({
72117
permissions: [],

src/services/ecosystem/registry.ts

Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,17 @@ function invalidateTTLCache(prefix: string): void {
136136

137137
// ==================== Fetch with ETag/Cache ====================
138138

139-
async function fetchSourceWithEtag(url: string): Promise<EcosystemSource | null> {
139+
type FetchSourceResult = {
140+
payload: EcosystemSource | null;
141+
errorMessage?: string;
142+
};
143+
144+
async function fetchSourceWithEtag(url: string): Promise<FetchSourceResult> {
140145
const fetchUrl = normalizeFetchUrl(url);
141146
const cacheKey = fetchUrl;
142147
// Check TTL cache first
143148
const ttlCached = getTTLCached<EcosystemSource>(`source:${cacheKey}`);
144-
if (ttlCached) return ttlCached;
149+
if (ttlCached) return { payload: ttlCached };
145150

146151
// Get cached entry for ETag
147152
const cached = await getCachedSource(cacheKey);
@@ -158,42 +163,45 @@ async function fetchSourceWithEtag(url: string): Promise<EcosystemSource | null>
158163
if (response.status === 304 && cached) {
159164
debugLog('fetch source not modified', { url, fetchUrl });
160165
setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS);
161-
return cached.data;
166+
return { payload: cached.data };
162167
}
163168

164169
if (!response.ok) {
170+
const message = `HTTP ${response.status}`;
165171
debugLog('fetch source failed', { url, fetchUrl, status: response.status });
166172
// Fall back to cache on error
167173
if (cached) {
168174
setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS);
169-
return cached.data;
175+
return { payload: cached.data, errorMessage: message };
170176
}
171-
return null;
177+
return { payload: null, errorMessage: message };
172178
}
173179

174180
const contentType = response.headers.get('content-type') ?? '';
175181
let json: unknown;
176182
try {
177183
json = await response.json();
178184
} catch (error) {
185+
const message = error instanceof Error ? error.message : String(error);
179186
debugLog('fetch source parse error', {
180187
url,
181188
fetchUrl,
182189
contentType,
183-
message: error instanceof Error ? error.message : String(error),
190+
message,
184191
});
185-
if (cached) return cached.data;
186-
return null;
192+
if (cached) return { payload: cached.data, errorMessage: message };
193+
return { payload: null, errorMessage: message };
187194
}
188195
const parsed = EcosystemSourceSchema.safeParse(json);
189196
if (!parsed.success) {
197+
const message = 'Invalid source payload';
190198
debugLog('fetch source invalid payload', {
191199
url,
192200
fetchUrl,
193201
issues: parsed.error.issues.map((issue) => issue.path.join('.')),
194202
});
195-
if (cached) return cached.data;
196-
return null;
203+
if (cached) return { payload: cached.data, errorMessage: message };
204+
return { payload: null, errorMessage: message };
197205
}
198206

199207
const data = parsed.data;
@@ -204,19 +212,20 @@ async function fetchSourceWithEtag(url: string): Promise<EcosystemSource | null>
204212
setTTLCached(`source:${cacheKey}`, data, TTL_MS);
205213
debugLog('fetch source success', { url, fetchUrl, apps: data.apps?.length ?? 0 });
206214

207-
return data;
215+
return { payload: data };
208216
} catch (error) {
217+
const message = error instanceof Error ? error.message : String(error);
209218
debugLog('fetch source error', {
210219
url,
211220
fetchUrl,
212-
message: error instanceof Error ? error.message : String(error),
221+
message,
213222
});
214223
// Fall back to cache on error
215224
if (cached) {
216225
setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS);
217-
return cached.data;
226+
return { payload: cached.data, errorMessage: message };
218227
}
219-
return null;
228+
return { payload: null, errorMessage: message };
220229
}
221230
}
222231

@@ -394,7 +403,8 @@ function normalizeAppFromSource(
394403
}
395404

396405
async function fetchSourceWithCache(url: string): Promise<EcosystemSource | null> {
397-
return fetchSourceWithEtag(url);
406+
const result = await fetchSourceWithEtag(url);
407+
return result.payload;
398408
}
399409

400410
async function rebuildCachedAppsFromCache(): Promise<void> {
@@ -454,38 +464,42 @@ export async function refreshSources(options?: { force?: boolean }): Promise<Min
454464
await Promise.all(
455465
enabledSources.map(async (source) => {
456466
ecosystemActions.updateSourceStatus(source.url, 'loading');
457-
try {
458-
const payload = await fetchSourceWithEtag(source.url);
467+
const result = await fetchSourceWithEtag(source.url);
468+
sourcePayloads.set(source.url, result.payload);
469+
470+
if (result.errorMessage) {
471+
ecosystemActions.updateSourceStatus(source.url, 'error', result.errorMessage);
472+
} else if (result.payload) {
459473
ecosystemActions.updateSourceStatus(source.url, 'success');
460-
sourcePayloads.set(source.url, payload);
461-
} catch (error) {
462-
const message = error instanceof Error ? error.message : 'Unknown error';
463-
ecosystemActions.updateSourceStatus(source.url, 'error', message);
464-
sourcePayloads.set(source.url, null);
465-
} finally {
466-
cachedApps = mergeAppsFromSources(enabledSources, sourcePayloads);
467-
notifyApps();
474+
} else {
475+
ecosystemActions.updateSourceStatus(source.url, 'error', 'Failed to fetch source');
468476
}
477+
478+
cachedApps = mergeAppsFromSources(enabledSources, sourcePayloads);
479+
notifyApps();
469480
}),
470481
);
471482
return [...cachedApps];
472483
}
473484

474485
export async function refreshSource(url: string): Promise<void> {
475486
ecosystemActions.updateSourceStatus(url, 'loading');
476-
try {
477-
invalidateTTLCache(`source:${url}`);
478-
const payload = await fetchSourceWithEtag(url);
479-
if (payload) {
480-
ecosystemActions.updateSourceStatus(url, 'success');
481-
await rebuildCachedAppsFromCache();
482-
} else {
483-
ecosystemActions.updateSourceStatus(url, 'error', 'Failed to fetch source');
484-
}
485-
} catch (error) {
486-
const message = error instanceof Error ? error.message : 'Unknown error';
487-
ecosystemActions.updateSourceStatus(url, 'error', message);
487+
invalidateTTLCache(`source:${url}`);
488+
const result = await fetchSourceWithEtag(url);
489+
490+
if (result.errorMessage) {
491+
ecosystemActions.updateSourceStatus(url, 'error', result.errorMessage);
492+
await rebuildCachedAppsFromCache();
493+
return;
488494
}
495+
496+
if (result.payload) {
497+
ecosystemActions.updateSourceStatus(url, 'success');
498+
await rebuildCachedAppsFromCache();
499+
return;
500+
}
501+
502+
ecosystemActions.updateSourceStatus(url, 'error', 'Failed to fetch source');
489503
}
490504

491505
export async function loadSource(url: string): Promise<EcosystemSource | null> {

src/stackflow/activities/SettingsSourcesActivity.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
IconArrowLeft,
2020
IconLoader2,
2121
IconAlertCircle,
22-
IconEdit,
2322
} from '@tabler/icons-react';
2423
import { ecosystemStore, ecosystemActions, type SourceRecord } from '@/stores/ecosystem';
2524
import { refreshSources, refreshSource } from '@/services/ecosystem/registry';
@@ -164,7 +163,7 @@ export const SettingsSourcesActivity: ActivityComponentType = () => {
164163
setIsRefreshing(true);
165164
try {
166165
await refreshSources();
167-
} catch (e) {}
166+
} catch {}
168167
setIsRefreshing(false);
169168
};
170169

@@ -286,12 +285,15 @@ function SourceItem({ source, onToggle, onRemove, onEdit, isSelected }: SourceIt
286285
const { t } = useTranslation('common');
287286
const isDefault = source.url.includes('ecosystem.json');
288287

289-
const statusIcon = {
290-
idle: null,
291-
loading: <IconLoader2 className="text-muted-foreground size-3 animate-spin" />,
292-
success: <IconCheck className="size-3 text-green-500" />,
293-
error: <IconAlertCircle className="size-3 text-red-500" />,
294-
}[source.status];
288+
const hasError = source.status === 'error' || (source.status !== 'loading' && Boolean(source.errorMessage));
289+
let statusIcon: React.ReactNode = null;
290+
if (hasError) {
291+
statusIcon = <IconAlertCircle className="size-3 text-red-500" />;
292+
} else if (source.status === 'loading') {
293+
statusIcon = <IconLoader2 className="text-muted-foreground size-3 animate-spin" />;
294+
} else if (source.status === 'success') {
295+
statusIcon = <IconCheck className="size-3 text-green-500" />;
296+
}
295297

296298
return (
297299
<div
@@ -320,8 +322,8 @@ function SourceItem({ source, onToggle, onRemove, onEdit, isSelected }: SourceIt
320322
<p className="text-muted-foreground text-xs">
321323
{t('sources.updatedAt', { date: new Date(source.lastUpdated).toLocaleString() })}
322324
</p>
323-
{source.status === 'error' && source.errorMessage && (
324-
<p className="truncate text-xs text-red-500">{source.errorMessage}</p>
325+
{hasError && (
326+
<p className="truncate text-xs text-red-500">{source.errorMessage ?? t('service.queryFailed')}</p>
325327
)}
326328
</div>
327329
</div>

vite.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ const remoteMiniappsConfig: RemoteMiniappConfig[] = [
1818
server: {
1919
locale: {
2020
metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json',
21-
dirName: 'rwa-hub',
21+
dirName: 'om-hub',
2222
},
2323
runtime: 'iframe',
2424
},
2525
build: {
2626
remote: {
27-
name: 'RWA',
28-
sourceUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/source.json',
27+
name: 'Open Market',
28+
sourceUrl: 'https://om-open.bf-meta.org/hub/source.json',
2929
},
3030
runtime: 'iframe',
3131
},

0 commit comments

Comments
 (0)