Spaces:
Running
Running
| 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(); | |
| }); | |