Reachy_Mini / server /index.js
tfrere's picture
tfrere HF Staff
fix: deduplicate forked spaces with identical repo names
faacae5
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();
});