diff --git a/.gitignore b/.gitignore index 53af34f2c..194c190ba 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ Thumbs.db # Remote miniapps (downloaded at dev/build time) miniapps/rwa-hub/ +miniapps/om-hub/ # Playwright e2e/test-results/ diff --git a/src/services/ecosystem/__tests__/registry.test.ts b/src/services/ecosystem/__tests__/registry.test.ts index 34be56cc7..c8cd5343d 100644 --- a/src/services/ecosystem/__tests__/registry.test.ts +++ b/src/services/ecosystem/__tests__/registry.test.ts @@ -67,6 +67,51 @@ describe('Miniapp Registry (Subscription v2)', () => { expect(getAppById('xin.dweb.teleport')?.name).toBe('Teleport') }) + + it('marks source as error when refresh falls back to cache after fetch failure', async () => { + const mockApps = [ + { + id: 'xin.dweb.teleport', + name: 'Teleport', + url: '/miniapps/teleport/', + icon: '/miniapps/teleport/icon.svg', + description: 'Test', + version: '1.0.0', + }, + ] + + const fetchMock = vi + .fn< + [RequestInfo | URL], + Promise<{ + ok: boolean; + status: number; + headers: Map; + json: () => Promise; + }> + >() + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map([['ETag', '"v1"']]), + json: () => Promise.resolve({ name: 'x', version: '1', updated: '2025-01-01', apps: mockApps }), + }) + .mockRejectedValueOnce(new Error('network down')) + + global.fetch = fetchMock as unknown as typeof fetch + + await refreshSources({ force: true }) + + expect(ecosystemStore.state.sources[0]?.status).toBe('success') + + const apps = await refreshSources({ force: true }) + + expect(apps).toHaveLength(1) + expect(getApps()).toHaveLength(1) + expect(ecosystemStore.state.sources[0]?.status).toBe('error') + expect(ecosystemStore.state.sources[0]?.errorMessage).toBe('network down') + }) + it('prefers the later source when duplicate app id exists', async () => { ecosystemStore.setState(() => ({ permissions: [], diff --git a/src/services/ecosystem/registry.ts b/src/services/ecosystem/registry.ts index 26872d949..da09e0823 100644 --- a/src/services/ecosystem/registry.ts +++ b/src/services/ecosystem/registry.ts @@ -136,12 +136,17 @@ function invalidateTTLCache(prefix: string): void { // ==================== Fetch with ETag/Cache ==================== -async function fetchSourceWithEtag(url: string): Promise { +type FetchSourceResult = { + payload: EcosystemSource | null; + errorMessage?: string; +}; + +async function fetchSourceWithEtag(url: string): Promise { const fetchUrl = normalizeFetchUrl(url); const cacheKey = fetchUrl; // Check TTL cache first const ttlCached = getTTLCached(`source:${cacheKey}`); - if (ttlCached) return ttlCached; + if (ttlCached) return { payload: ttlCached }; // Get cached entry for ETag const cached = await getCachedSource(cacheKey); @@ -158,17 +163,18 @@ async function fetchSourceWithEtag(url: string): Promise if (response.status === 304 && cached) { debugLog('fetch source not modified', { url, fetchUrl }); setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS); - return cached.data; + return { payload: cached.data }; } if (!response.ok) { + const message = `HTTP ${response.status}`; debugLog('fetch source failed', { url, fetchUrl, status: response.status }); // Fall back to cache on error if (cached) { setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS); - return cached.data; + return { payload: cached.data, errorMessage: message }; } - return null; + return { payload: null, errorMessage: message }; } const contentType = response.headers.get('content-type') ?? ''; @@ -176,24 +182,26 @@ async function fetchSourceWithEtag(url: string): Promise try { json = await response.json(); } catch (error) { + const message = error instanceof Error ? error.message : String(error); debugLog('fetch source parse error', { url, fetchUrl, contentType, - message: error instanceof Error ? error.message : String(error), + message, }); - if (cached) return cached.data; - return null; + if (cached) return { payload: cached.data, errorMessage: message }; + return { payload: null, errorMessage: message }; } const parsed = EcosystemSourceSchema.safeParse(json); if (!parsed.success) { + const message = 'Invalid source payload'; debugLog('fetch source invalid payload', { url, fetchUrl, issues: parsed.error.issues.map((issue) => issue.path.join('.')), }); - if (cached) return cached.data; - return null; + if (cached) return { payload: cached.data, errorMessage: message }; + return { payload: null, errorMessage: message }; } const data = parsed.data; @@ -204,19 +212,20 @@ async function fetchSourceWithEtag(url: string): Promise setTTLCached(`source:${cacheKey}`, data, TTL_MS); debugLog('fetch source success', { url, fetchUrl, apps: data.apps?.length ?? 0 }); - return data; + return { payload: data }; } catch (error) { + const message = error instanceof Error ? error.message : String(error); debugLog('fetch source error', { url, fetchUrl, - message: error instanceof Error ? error.message : String(error), + message, }); // Fall back to cache on error if (cached) { setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS); - return cached.data; + return { payload: cached.data, errorMessage: message }; } - return null; + return { payload: null, errorMessage: message }; } } @@ -394,7 +403,8 @@ function normalizeAppFromSource( } async function fetchSourceWithCache(url: string): Promise { - return fetchSourceWithEtag(url); + const result = await fetchSourceWithEtag(url); + return result.payload; } async function rebuildCachedAppsFromCache(): Promise { @@ -454,18 +464,19 @@ export async function refreshSources(options?: { force?: boolean }): Promise { ecosystemActions.updateSourceStatus(source.url, 'loading'); - try { - const payload = await fetchSourceWithEtag(source.url); + const result = await fetchSourceWithEtag(source.url); + sourcePayloads.set(source.url, result.payload); + + if (result.errorMessage) { + ecosystemActions.updateSourceStatus(source.url, 'error', result.errorMessage); + } else if (result.payload) { ecosystemActions.updateSourceStatus(source.url, 'success'); - sourcePayloads.set(source.url, payload); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - ecosystemActions.updateSourceStatus(source.url, 'error', message); - sourcePayloads.set(source.url, null); - } finally { - cachedApps = mergeAppsFromSources(enabledSources, sourcePayloads); - notifyApps(); + } else { + ecosystemActions.updateSourceStatus(source.url, 'error', 'Failed to fetch source'); } + + cachedApps = mergeAppsFromSources(enabledSources, sourcePayloads); + notifyApps(); }), ); return [...cachedApps]; @@ -473,19 +484,22 @@ export async function refreshSources(options?: { force?: boolean }): Promise { ecosystemActions.updateSourceStatus(url, 'loading'); - try { - invalidateTTLCache(`source:${url}`); - const payload = await fetchSourceWithEtag(url); - if (payload) { - ecosystemActions.updateSourceStatus(url, 'success'); - await rebuildCachedAppsFromCache(); - } else { - ecosystemActions.updateSourceStatus(url, 'error', 'Failed to fetch source'); - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - ecosystemActions.updateSourceStatus(url, 'error', message); + invalidateTTLCache(`source:${url}`); + const result = await fetchSourceWithEtag(url); + + if (result.errorMessage) { + ecosystemActions.updateSourceStatus(url, 'error', result.errorMessage); + await rebuildCachedAppsFromCache(); + return; } + + if (result.payload) { + ecosystemActions.updateSourceStatus(url, 'success'); + await rebuildCachedAppsFromCache(); + return; + } + + ecosystemActions.updateSourceStatus(url, 'error', 'Failed to fetch source'); } export async function loadSource(url: string): Promise { diff --git a/src/stackflow/activities/SettingsSourcesActivity.tsx b/src/stackflow/activities/SettingsSourcesActivity.tsx index f3fb1077c..f3e79b505 100644 --- a/src/stackflow/activities/SettingsSourcesActivity.tsx +++ b/src/stackflow/activities/SettingsSourcesActivity.tsx @@ -19,7 +19,6 @@ import { IconArrowLeft, IconLoader2, IconAlertCircle, - IconEdit, } from '@tabler/icons-react'; import { ecosystemStore, ecosystemActions, type SourceRecord } from '@/stores/ecosystem'; import { refreshSources, refreshSource } from '@/services/ecosystem/registry'; @@ -164,7 +163,7 @@ export const SettingsSourcesActivity: ActivityComponentType = () => { setIsRefreshing(true); try { await refreshSources(); - } catch (e) {} + } catch {} setIsRefreshing(false); }; @@ -286,12 +285,15 @@ function SourceItem({ source, onToggle, onRemove, onEdit, isSelected }: SourceIt const { t } = useTranslation('common'); const isDefault = source.url.includes('ecosystem.json'); - const statusIcon = { - idle: null, - loading: , - success: , - error: , - }[source.status]; + const hasError = source.status === 'error' || (source.status !== 'loading' && Boolean(source.errorMessage)); + let statusIcon: React.ReactNode = null; + if (hasError) { + statusIcon = ; + } else if (source.status === 'loading') { + statusIcon = ; + } else if (source.status === 'success') { + statusIcon = ; + } return (
{t('sources.updatedAt', { date: new Date(source.lastUpdated).toLocaleString() })}

- {source.status === 'error' && source.errorMessage && ( -

{source.errorMessage}

+ {hasError && ( +

{source.errorMessage ?? t('service.queryFailed')}

)}
diff --git a/vite.config.ts b/vite.config.ts index 07c5a6e35..89562b73d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,14 +18,14 @@ const remoteMiniappsConfig: RemoteMiniappConfig[] = [ server: { locale: { metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json', - dirName: 'rwa-hub', + dirName: 'om-hub', }, runtime: 'iframe', }, build: { remote: { - name: 'RWA', - sourceUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/source.json', + name: 'Open Market', + sourceUrl: 'https://om-open.bf-meta.org/hub/source.json', }, runtime: 'iframe', },