import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 7860; // Cache configuration const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json'; const HF_SPACES_API = 'https://huggingface.co/api/spaces'; // Note: HF API doesn't support pagination with filter=, so we use a high limit const HF_SPACES_LIMIT = 1000; // In-memory cache let appsCache = { data: null, lastFetch: null, fetching: false, }; // Fetch apps from HuggingFace API // Returns format compatible with desktop app (with url, source_kind, extra) async function fetchAppsFromHF() { console.log('[Cache] Fetching apps from HuggingFace API...'); try { // 1. Fetch official app IDs const officialResponse = await fetch(OFFICIAL_APP_LIST_URL); let officialIdList = []; if (officialResponse.ok) { officialIdList = await officialResponse.json(); } const officialSet = new Set(officialIdList.map(id => id.toLowerCase())); // 2. Fetch all spaces with reachy_mini tag // Note: HF API doesn't support pagination with filter=, so we use a high limit const spacesResponse = await fetch(`${HF_SPACES_API}?filter=reachy_mini&full=true&limit=${HF_SPACES_LIMIT}`); if (!spacesResponse.ok) { throw new Error(`HF API returned ${spacesResponse.status}`); } const allSpaces = await spacesResponse.json(); console.log(`[Cache] Fetched ${allSpaces.length} spaces from HuggingFace`); // 3. Build apps list in desktop-compatible format const allApps = allSpaces.map(space => { const spaceId = space.id || ''; const tags = space.tags || []; const isOfficial = officialSet.has(spaceId.toLowerCase()); const isPythonApp = tags.includes('reachy_mini_python_app'); const author = spaceId.split('/')[0]; const name = spaceId.split('/').pop(); return { // Core fields (used by both website and desktop) id: spaceId, name, description: space.cardData?.short_description || '', url: `https://huggingface.co/spaces/${spaceId}`, source_kind: 'hf_space', isOfficial, // Extra metadata (desktop-compatible structure) extra: { id: spaceId, author, likes: space.likes || 0, downloads: space.downloads || 0, createdAt: space.createdAt || null, lastModified: space.lastModified, runtime: space.runtime || null, tags, isPythonApp, cardData: { emoji: space.cardData?.emoji || (isPythonApp ? '📦' : '🌐'), short_description: space.cardData?.short_description || '', sdk: space.cardData?.sdk || null, tags: space.cardData?.tags || [], // Preserve other cardData fields ...space.cardData, }, }, }; }); // Deduplicate by name: forks keep the same repo name (e.g. 4 spaces // named "reachy_mini_conversation_app" from different authors). // Priority: 1) official, 2) oldest (original), 3) most likes as tiebreaker. const deduped = new Map(); for (const app of allApps) { const key = app.name.toLowerCase(); const existing = deduped.get(key); if (!existing) { deduped.set(key, app); continue; } // Official always wins if (app.isOfficial && !existing.isOfficial) { deduped.set(key, app); continue; } if (existing.isOfficial) continue; // Oldest wins (the original is created before its forks) const appDate = app.extra.createdAt ? new Date(app.extra.createdAt).getTime() : Infinity; const existingDate = existing.extra.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity; if (appDate < existingDate) { deduped.set(key, app); } else if (appDate === existingDate && (app.extra.likes || 0) > (existing.extra.likes || 0)) { deduped.set(key, app); } } const uniqueApps = [...deduped.values()]; console.log(`[Cache] Deduplicated ${allApps.length} → ${uniqueApps.length} apps (removed ${allApps.length - uniqueApps.length} forks with duplicate names)`); // Sort: official first, then by likes uniqueApps.sort((a, b) => { if (a.isOfficial !== b.isOfficial) { return a.isOfficial ? -1 : 1; } return (b.extra.likes || 0) - (a.extra.likes || 0); }); return uniqueApps; } catch (err) { console.error('[Cache] Error fetching apps:', err); throw err; } } // Get apps with caching async function getApps() { const now = Date.now(); // Return cache if valid if (appsCache.data && appsCache.lastFetch && (now - appsCache.lastFetch) < CACHE_TTL_MS) { const ageMinutes = Math.round((now - appsCache.lastFetch) / 60000); console.log(`[Cache] Returning cached data (age: ${ageMinutes} min)`); return appsCache.data; } // Prevent concurrent fetches if (appsCache.fetching) { console.log('[Cache] Fetch already in progress, returning stale data'); return appsCache.data || []; } appsCache.fetching = true; try { const apps = await fetchAppsFromHF(); appsCache.data = apps; appsCache.lastFetch = now; console.log(`[Cache] Cache updated with ${apps.length} apps`); return apps; } catch (err) { // On error, return stale cache if available if (appsCache.data) { console.log('[Cache] Fetch failed, returning stale cache'); return appsCache.data; } throw err; } finally { appsCache.fetching = false; } } // API endpoint app.get('/api/apps', async (req, res) => { try { const apps = await getApps(); res.json({ apps, cached: true, cacheAge: appsCache.lastFetch ? Math.round((Date.now() - appsCache.lastFetch) / 1000) : 0, count: apps.length, }); } catch (err) { console.error('[API] Error:', err); res.status(500).json({ error: 'Failed to fetch apps' }); } }); // OAuth config endpoint - expose public OAuth variables to the frontend // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces) app.get('/api/oauth-config', (req, res) => { const clientId = process.env.OAUTH_CLIENT_ID; const scopes = process.env.OAUTH_SCOPES || 'openid profile'; if (!clientId) { return res.status(503).json({ error: 'OAuth not configured', hint: 'Make sure hf_oauth: true is set in README.md and the Space has been rebuilt', }); } res.json({ clientId, scopes }); }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', cacheStatus: appsCache.data ? 'warm' : 'cold', cacheAge: appsCache.lastFetch ? Math.round((Date.now() - appsCache.lastFetch) / 1000) : null, appsCount: appsCache.data?.length || 0, }); }); // Force cache refresh (for admin use) app.post('/api/refresh', async (req, res) => { try { appsCache.lastFetch = null; // Invalidate cache const apps = await getApps(); res.json({ success: true, count: apps.length }); } catch (err) { res.status(500).json({ error: 'Failed to refresh cache' }); } }); // Serve static files from the dist folder app.use(express.static(path.join(__dirname, '../dist'), { maxAge: '1y', etag: true, })); // SPA fallback - serve index.html for all other routes app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../dist/index.html')); }); // Pre-warm cache on startup async function warmCache() { console.log('[Startup] Pre-warming cache...'); try { await getApps(); console.log('[Startup] Cache warmed successfully'); } catch (err) { console.error('[Startup] Failed to warm cache:', err); } } // Start server app.listen(PORT, () => { console.log(`[Server] Running on port ${PORT}`); warmCache(); });