File size: 8,088 Bytes
e8700c6
 
 
 
 
 
 
 
 
 
eee87e9
e8700c6
 
 
 
 
 
 
 
 
 
 
 
 
4d77ab0
e8700c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d77ab0
e8700c6
 
 
 
 
4d77ab0
 
e8700c6
 
4d77ab0
e8700c6
4d77ab0
e8700c6
4d77ab0
 
e8700c6
4d77ab0
 
 
 
 
 
 
faacae5
4d77ab0
 
 
 
 
 
 
 
 
 
 
 
 
e8700c6
 
 
faacae5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e8700c6
faacae5
e8700c6
 
 
4d77ab0
e8700c6
 
faacae5
e8700c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45671b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e8700c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
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();
});