Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Thumbs.db

# Remote miniapps (downloaded at dev/build time)
miniapps/rwa-hub/
miniapps/om-hub/

# Playwright
e2e/test-results/
Expand Down
45 changes: 45 additions & 0 deletions src/services/ecosystem/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
json: () => Promise<unknown>;
}>
>()
.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: [],
Expand Down
88 changes: 51 additions & 37 deletions src/services/ecosystem/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,17 @@ function invalidateTTLCache(prefix: string): void {

// ==================== Fetch with ETag/Cache ====================

async function fetchSourceWithEtag(url: string): Promise<EcosystemSource | null> {
type FetchSourceResult = {
payload: EcosystemSource | null;
errorMessage?: string;
};

async function fetchSourceWithEtag(url: string): Promise<FetchSourceResult> {
const fetchUrl = normalizeFetchUrl(url);
const cacheKey = fetchUrl;
// Check TTL cache first
const ttlCached = getTTLCached<EcosystemSource>(`source:${cacheKey}`);
if (ttlCached) return ttlCached;
if (ttlCached) return { payload: ttlCached };

// Get cached entry for ETag
const cached = await getCachedSource(cacheKey);
Expand All @@ -158,42 +163,45 @@ async function fetchSourceWithEtag(url: string): Promise<EcosystemSource | null>
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') ?? '';
let json: unknown;
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;
Expand All @@ -204,19 +212,20 @@ async function fetchSourceWithEtag(url: string): Promise<EcosystemSource | null>
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 };
}
}

Expand Down Expand Up @@ -394,7 +403,8 @@ function normalizeAppFromSource(
}

async function fetchSourceWithCache(url: string): Promise<EcosystemSource | null> {
return fetchSourceWithEtag(url);
const result = await fetchSourceWithEtag(url);
return result.payload;
}

async function rebuildCachedAppsFromCache(): Promise<void> {
Expand Down Expand Up @@ -454,38 +464,42 @@ export async function refreshSources(options?: { force?: boolean }): Promise<Min
await Promise.all(
enabledSources.map(async (source) => {
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];
}

export async function refreshSource(url: string): Promise<void> {
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<EcosystemSource | null> {
Expand Down
22 changes: 12 additions & 10 deletions src/stackflow/activities/SettingsSourcesActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -164,7 +163,7 @@ export const SettingsSourcesActivity: ActivityComponentType = () => {
setIsRefreshing(true);
try {
await refreshSources();
} catch (e) {}
} catch {}
setIsRefreshing(false);
};

Expand Down Expand Up @@ -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: <IconLoader2 className="text-muted-foreground size-3 animate-spin" />,
success: <IconCheck className="size-3 text-green-500" />,
error: <IconAlertCircle className="size-3 text-red-500" />,
}[source.status];
const hasError = source.status === 'error' || (source.status !== 'loading' && Boolean(source.errorMessage));
let statusIcon: React.ReactNode = null;
if (hasError) {
statusIcon = <IconAlertCircle className="size-3 text-red-500" />;
} else if (source.status === 'loading') {
statusIcon = <IconLoader2 className="text-muted-foreground size-3 animate-spin" />;
} else if (source.status === 'success') {
statusIcon = <IconCheck className="size-3 text-green-500" />;
}

return (
<div
Expand Down Expand Up @@ -320,8 +322,8 @@ function SourceItem({ source, onToggle, onRemove, onEdit, isSelected }: SourceIt
<p className="text-muted-foreground text-xs">
{t('sources.updatedAt', { date: new Date(source.lastUpdated).toLocaleString() })}
</p>
{source.status === 'error' && source.errorMessage && (
<p className="truncate text-xs text-red-500">{source.errorMessage}</p>
{hasError && (
<p className="truncate text-xs text-red-500">{source.errorMessage ?? t('service.queryFailed')}</p>
)}
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down