Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import compression from 'compression'; | |
| import express from 'express'; | |
| import { existsSync, readFileSync } from 'fs'; | |
| import path from 'path'; | |
| import { fileURLToPath } from 'url'; | |
| import { categorizeApp, HfTokenMissingError } from './categorize.js'; | |
| import { categoryCache } from './categoryCache.js'; | |
| import { getPublicTaxonomy, loadTaxonomyFromDataset } from './categories.js'; | |
| import { moderateApp } from './moderate.js'; | |
| import { moderationCache } from './moderationCache.js'; | |
| import { decideVisibility } from './visibility.js'; | |
| import { | |
| dedupToolsByName, | |
| fetchMcpToolsFromHF, | |
| MCP_TOOL_TAG, | |
| } from './mcpTools.js'; | |
| import { mintEphemeralKeyHandler } from './openaiEphemeral.js'; | |
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| // Load `.env` from the repo root in dev. In production (HF Space) | |
| // the platform already injects the secrets as env vars, so this | |
| // loader silently no-ops. We avoid the `dotenv` dep on purpose - | |
| // the format is trivial, and reproducing it inline keeps the | |
| // runtime closure tiny. | |
| (function loadDotenv() { | |
| try { | |
| const envPath = path.join(__dirname, '..', '.env'); | |
| if (!existsSync(envPath)) return; | |
| const text = readFileSync(envPath, 'utf8'); | |
| for (const line of text.split(/\r?\n/)) { | |
| const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i); | |
| if (!m) continue; | |
| const [, key, raw] = m; | |
| let value = raw; | |
| if ( | |
| (value.startsWith('"') && value.endsWith('"')) || | |
| (value.startsWith("'") && value.endsWith("'")) | |
| ) { | |
| value = value.slice(1, -1); | |
| } | |
| // Existing env wins (so `HF_TOKEN=foo node …` overrides .env). | |
| if (process.env[key] === undefined) process.env[key] = value; | |
| } | |
| } catch { | |
| /* best-effort - missing or malformed .env never blocks boot */ | |
| } | |
| })(); | |
| const app = express(); | |
| const PORT = process.env.PORT || 7860; | |
| // gzip/brotli compression on every response. Critical for the | |
| // catalog endpoints (`/api/apps`, `/api/js-apps`) which return | |
| // ~40KB of JSON dominated by repeated keys ("apps", "id", "extra", | |
| // "cardData"…) - gzip cuts that to ~6KB on the wire. The Express | |
| // `compression` middleware: | |
| // - skips responses already encoded (no double-encoding), | |
| // - skips responses below the `threshold` (default 1KB - tiny | |
| // payloads stay verbatim since the gzip framing would dwarf | |
| // the savings), | |
| // - honours the client's `Accept-Encoding`, falling back to | |
| // identity when the client doesn't speak gzip/br. | |
| // No streaming endpoints in this server (every route ends in | |
| // `res.json()` or `res.sendFile()`), so compression is unconditionally | |
| // safe. The default `level: 6` is the right CPU/ratio trade-off for | |
| // JSON. | |
| app.use(compression()); | |
| // JSON body parsing for the handful of POST routes that consume | |
| // structured payloads (currently `/api/openai/ephemeral`). The 8KB | |
| // cap is intentionally tiny because none of our endpoints accept | |
| // large bodies, and a tight limit drops obvious abuse early. | |
| app.use(express.json({ limit: '8kb' })); | |
| // CORS allowlist for cross-origin API consumers. Same-origin browser | |
| // calls from this Space stay unaffected. The mobile shell runs from | |
| // `https://tauri.localhost` (iOS WKWebView), `http://tauri.localhost` | |
| // (Android WebView), and the desktop dev preview from | |
| // `http://localhost:1422` (Vite). We do NOT use a wildcard origin | |
| // because every allowed call expects `Authorization: Bearer …`, and | |
| // `Access-Control-Allow-Origin: *` is incompatible with credentialed | |
| // CORS in any practical setup. | |
| const CORS_ALLOWED_ORIGINS = new Set([ | |
| // Mobile shell (Tauri WebView) | |
| 'https://tauri.localhost', | |
| 'http://tauri.localhost', | |
| // Desktop / Vite dev previews | |
| 'http://localhost:1422', | |
| 'http://localhost:1420', | |
| 'http://localhost:5173', | |
| // Showcase website Space (now a separate static deploy that calls | |
| // this API cross-origin). Static Spaces are served from the | |
| // `.static.hf.space` host, so that exact origin is what the browser | |
| // sends. Keep the plain `.hf.space` variant too in case of aliasing. | |
| // Test (tfrere) + prod (pollen-robotics). | |
| 'https://tfrere-reachy-mini-website.static.hf.space', | |
| 'https://pollen-robotics-reachy-mini-website.static.hf.space', | |
| 'https://tfrere-reachy-mini-website.hf.space', | |
| 'https://pollen-robotics-reachy-mini-website.hf.space', | |
| ]); | |
| app.use((req, res, next) => { | |
| const origin = req.headers.origin; | |
| if (origin && CORS_ALLOWED_ORIGINS.has(origin)) { | |
| res.setHeader('Access-Control-Allow-Origin', origin); | |
| res.setHeader('Vary', 'Origin'); | |
| res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); | |
| res.setHeader( | |
| 'Access-Control-Allow-Headers', | |
| 'Authorization, Content-Type', | |
| ); | |
| // Expose `Age` so cross-origin JS clients (mobile shell, desktop | |
| // store, anything not running same-origin on this Space) can | |
| // read the server-side cache age. The header lives in the | |
| // CORS-safelisted set only for a hardcoded handful of fields; | |
| // `Age` is NOT in that set, so without this header browser | |
| // `fetch()` callers would see `null` from `headers.get('age')`. | |
| // We could also expose `ETag` here for clients that want to | |
| // do manual `If-None-Match` revalidation, but the browser | |
| // handles ETag transparently in its own HTTP cache, so JS | |
| // never needs to see it. | |
| res.setHeader('Access-Control-Expose-Headers', 'Age'); | |
| res.setHeader('Access-Control-Max-Age', '600'); | |
| } | |
| if (req.method === 'OPTIONS') return res.sendStatus(204); | |
| next(); | |
| }); | |
| // Cache configuration | |
| const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes | |
| // ===================================================================== | |
| // Store control-plane dataset (single source of truth) | |
| // ===================================================================== | |
| // | |
| // One HF dataset holds everything that drives the store catalog, | |
| // split by nature into two folders: | |
| // | |
| // config/ hand-edited, source of truth, precious (humans only) | |
| // - official-app-list.json : official app IDs (curated by Pollen) | |
| // - official-tool-list.json : official MCP tool Space IDs (optional) | |
| // - blocked-app-list.json : blocked Space IDs (killswitch, global: | |
| // covers both apps and MCP tools) | |
| // - taxonomy.json : category list (slugs/labels/descriptions) | |
| // cache/ machine-written, regenerable, disposable (server only) | |
| // - categories.json : LLM category cache (written by this server) | |
| // - moderation.json : moderation verdict cache (written by server) | |
| // | |
| // `config/*` is READ-only from the server (humans edit it on the Hub - | |
| // anyone with dataset write access can promote/block an app or change | |
| // the taxonomy without a code deploy). `cache/*` is WRITTEN by the | |
| // server, so the Space's HF_TOKEN must have WRITE access. Each cache | |
| // commits only its own file (`addOrUpdate` on a single path), so | |
| // server writes never clobber the hand-edited config, and `cache/` | |
| // can be wiped at any time (the server repopulates it). | |
| // | |
| // `STORE_DATASET` is the single knob: point it at any `namespace/name` | |
| // and every file follows. The category/moderation caches and the | |
| // taxonomy loader read the same env, so there is exactly one place to | |
| // change. | |
| export const STORE_DATASET = | |
| process.env.STORE_DATASET || 'pollen-robotics/reachy_mini_store_data'; | |
| const STORE_DATASET_RAW = `https://huggingface.co/datasets/${STORE_DATASET}/raw/main`; | |
| const OFFICIAL_APP_LIST_URL = `${STORE_DATASET_RAW}/config/official-app-list.json`; | |
| // Tool-only official list, unioned with the app list above by | |
| // `fetchMcpToolsFromHF`. Lets Pollen vouch for a first-party MCP tool | |
| // Space (skipping moderation, as for official apps) without promoting it | |
| // in the app catalog. Optional: a 404 just means "no official tools". | |
| const OFFICIAL_TOOL_LIST_URL = `${STORE_DATASET_RAW}/config/official-tool-list.json`; | |
| const BLOCK_LIST_URL = `${STORE_DATASET_RAW}/config/blocked-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; | |
| /** | |
| * Standard HTTP caching for the catalog GET endpoints | |
| * (`/api/apps`, `/api/js-apps`). | |
| * | |
| * Why bake this into a helper instead of inlining the same two | |
| * `setHeader` calls in every route: | |
| * 1. Both endpoints share the same upstream cache state | |
| * (`appsCache.lastFetch`) so they SHOULD emit a coherent | |
| * `Age` value - any drift between routes would silently | |
| * mislead clients about cache staleness. | |
| * 2. The `Cache-Control` directives below were chosen carefully; | |
| * a future contributor copy-pasting one route to start a new | |
| * catalog projection should inherit them rather than rolling | |
| * their own. | |
| * | |
| * Cache-Control: `public, max-age=60, stale-while-revalidate=300` | |
| * - `public`: response is safe to store in shared caches (the | |
| * payload is identical for every caller, no per-user data). | |
| * - `max-age=60`: clients + intermediaries may serve this | |
| * response for up to 60 s without revalidating. The upstream | |
| * `appsCache` already deduplicates within a 5-minute window | |
| * server-side, so 60 s here means the network sees at most | |
| * 1 hit/minute per cache key per intermediate even under | |
| * burst load (10k mobile shells waking up at the same time). | |
| * - `stale-while-revalidate=300`: for a further 5 minutes after | |
| * the response goes stale, intermediaries may serve the | |
| * stale copy while revalidating in the background. This | |
| * absorbs sudden traffic spikes without ever blocking the | |
| * user on a cold-cache fetch. | |
| * | |
| * `Age` (RFC 7234 §5.1) replaces the `cacheAge` field we used to | |
| * pack into the response body. Pulling the age out of the body | |
| * was a strict prerequisite for ETag-based revalidation: Express's | |
| * default ETag is a hash of the response body, and a body that | |
| * carries a counter that increments every second produces a fresh | |
| * ETag every second, which makes `If-None-Match` permanently | |
| * negative and turns the ETag into dead weight. With `cacheAge` | |
| * promoted to a header, the body becomes a pure function of the | |
| * cache contents, the ETag becomes stable across requests that | |
| * hit the same cache snapshot, and clients sending `If-None-Match` | |
| * get cheap 304s instead of re-downloading 40 KB of JSON. | |
| */ | |
| function setCatalogCacheHeaders(res, lastFetchMs) { | |
| res.setHeader( | |
| 'Cache-Control', | |
| 'public, max-age=60, stale-while-revalidate=300', | |
| ); | |
| const ageSeconds = lastFetchMs | |
| ? Math.max(0, Math.round((Date.now() - lastFetchMs) / 1000)) | |
| : 0; | |
| res.setHeader('Age', String(ageSeconds)); | |
| } | |
| // Tag that gates the JS-only subset surfaced by /api/js-apps and | |
| // fed to the LLM categorizer. Mirrors the filter the mobile shell | |
| // applies today client-side; the route lets us retire that filter | |
| // from the mobile codebase down the line. | |
| const JS_APP_TAG = 'reachy_mini_js_app'; | |
| // ===================================================================== | |
| // App icon convention | |
| // ===================================================================== | |
| // | |
| // Convention: an app commits `public/icon.svg` (preferred) or | |
| // `public/icon.png` in its HF Space repository. When present, the | |
| // mobile shell + desktop store render it as the app glyph instead | |
| // of the front-matter `emoji:` codepoint. | |
| // | |
| // Why `public/` and not the repo root? | |
| // - Vite already copies `public/*` verbatim to `dist/` at build, | |
| // where nginx serves it at `/icon.svg`. The same file is | |
| // therefore the favicon, the `mountHost({ appIconUrl })` value, | |
| // AND the store glyph - one source of truth, no `cp` script, | |
| // no risk of the two copies drifting apart. | |
| // - HF `resolve/main/public/icon.svg` works the same as | |
| // `resolve/main/icon.svg`: any path inside the repo is | |
| // reachable, so the catalog still grabs the bytes without | |
| // waking the Space's nginx. | |
| // | |
| // We resolve the icon ONCE at indexing time (here) rather than | |
| // probing per-client because: | |
| // 1. We already pull `siblings` from `?full=true` (one cheap | |
| // hub call returns the file list for every app), so the | |
| // lookup is a pure JS filter, no extra network. | |
| // 2. Clients see a single field (`iconUrl`) in the payload and | |
| // don't have to know about HF resolve URLs, LFS pointers, | |
| // or the candidate-order race ("SVG wins if both exist"). | |
| // 3. The HF API caps probes at ~hub side; doing it server-side | |
| // keeps fanout under a 5-minute TTL behind ONE token, instead | |
| // of every mobile shell hammering `huggingface.co/resolve/` | |
| // to discover icons. | |
| // | |
| // Resolution order: `public/icon.svg` → `public/icon.png`. SVG | |
| // first because the same asset scales cleanly across every mount | |
| // point (small rail tile, larger pinned tile, iframe header) from | |
| // a single file. Extra formats can be added to `ICON_CANDIDATES` | |
| // if needed; order matters - the first match wins. | |
| const ICON_CANDIDATES = ['public/icon.svg', 'public/icon.png']; | |
| /** | |
| * Look for a standard app icon file at the conventional location. | |
| * Returns the absolute HF resolve URL when found, `null` otherwise. | |
| * | |
| * We hit `resolve/main/` (not `raw/main/`) so: | |
| * - LFS pointers follow transparently (large PNGs work). | |
| * - `Content-Type` comes from the extension, which `<img>` needs. | |
| * - The URL is cacheable cross-session by the browser, so | |
| * repeated mounts of the same app glyph don't re-fetch. | |
| */ | |
| function findIconUrl(spaceId, siblings) { | |
| if (!spaceId || !Array.isArray(siblings)) return null; | |
| // Build a Set of repo-relative filenames for O(1) candidate | |
| // lookups. HF returns `siblings` as `[{ rfilename: "path/in/repo" }, ...]`; | |
| // we keep the full path because the convention now lives under | |
| // `public/` rather than at the repo root. | |
| const files = new Set(); | |
| for (const s of siblings) { | |
| const name = s && typeof s.rfilename === 'string' ? s.rfilename : null; | |
| if (!name) continue; | |
| files.add(name); | |
| } | |
| for (const candidate of ICON_CANDIDATES) { | |
| if (files.has(candidate)) { | |
| return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`; | |
| } | |
| } | |
| return null; | |
| } | |
| // Serialised LLM batch concurrency: we want at most one | |
| // categorization sweep running at a time, regardless of how many | |
| // /api/js-apps requests come in. The flag also prevents the | |
| // startup warm-up and an on-demand refresh from racing each other. | |
| let categorizationBatchRunning = false; | |
| // Same idea for the moderation sweep - independent flag so a | |
| // moderation batch and a categorization batch can run concurrently | |
| // (they hit the same HF Inference token but are otherwise unrelated), | |
| // while two moderation batches never overlap. | |
| let moderationBatchRunning = false; | |
| // In-memory mirror of the hand-edited blocked-app-list.json. Refreshed | |
| // alongside the apps cache (see `fetchAppsFromHF`). Lower-cased IDs. | |
| let blockedSet = new Set(); | |
| // In-memory cache | |
| let appsCache = { | |
| data: null, | |
| lastFetch: null, | |
| fetching: false, | |
| }; | |
| // Independent in-memory cache for the MCP tool catalog. Kept separate | |
| // from `appsCache` because it queries a different HF tag filter | |
| // (`reachy-mini-tool` vs `reachy_mini`) and the two catalogs barely | |
| // overlap - sharing one cache would force both to refetch together. | |
| let mcpToolsCache = { | |
| 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 + the manual block-list (killswitch). | |
| // Both are plain JSON arrays of Space IDs under the dataset's config/. | |
| const [officialResponse, blockResponse] = await Promise.all([ | |
| fetch(OFFICIAL_APP_LIST_URL), | |
| fetch(BLOCK_LIST_URL).catch(() => null), | |
| ]); | |
| let officialIdList = []; | |
| if (officialResponse.ok) { | |
| officialIdList = await officialResponse.json(); | |
| } | |
| const officialSet = new Set(officialIdList.map(id => id.toLowerCase())); | |
| // Block-list is best-effort: a missing file (404, the common | |
| // case until the first kill) just means "nothing blocked". | |
| let blockedIdList = []; | |
| if (blockResponse && blockResponse.ok) { | |
| blockedIdList = await blockResponse.json().catch(() => []); | |
| } | |
| blockedSet = new Set( | |
| (Array.isArray(blockedIdList) ? blockedIdList : []).map((id) => | |
| String(id).toLowerCase(), | |
| ), | |
| ); | |
| if (blockedSet.size > 0) { | |
| console.log(`[Cache] Block-list: ${blockedSet.size} Space(s) hidden.`); | |
| } | |
| // 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 isBlocked = blockedSet.has(spaceId.toLowerCase()); | |
| const isPythonApp = tags.includes('reachy_mini_python_app'); | |
| const author = spaceId.split('/')[0]; | |
| const name = spaceId.split('/').pop(); | |
| // Server-resolved icon URL. Looks for `public/icon.svg` or | |
| // `public/icon.png` via the `siblings` list returned by | |
| // `?full=true`. See `findIconUrl()` above for the rationale. | |
| // `null` when the author hasn't shipped one; clients fall | |
| // back to the front-matter emoji. | |
| const iconUrl = findIconUrl(spaceId, space.siblings); | |
| 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, | |
| isBlocked, | |
| iconUrl, | |
| // 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, | |
| }, | |
| }, | |
| }; | |
| }); | |
| console.log(`[Cache] Built ${allApps.length} raw app entries from HF.`); | |
| // Sort: official first, then by likes. Dedup is route-specific | |
| // and applied downstream (see `dedupGlobalApps` and `dedupJsApps`). | |
| allApps.sort((a, b) => { | |
| if (a.isOfficial !== b.isOfficial) { | |
| return a.isOfficial ? -1 : 1; | |
| } | |
| return (b.extra.likes || 0) - (a.extra.likes || 0); | |
| }); | |
| return allApps; | |
| } catch (err) { | |
| console.error('[Cache] Error fetching apps:', err); | |
| throw err; | |
| } | |
| } | |
| /** | |
| * Pick a winner among Spaces sharing the same repo name. Forks | |
| * keep the upstream name (e.g. several `reachy_mini_conversation_app` | |
| * from different authors); we surface only one in the store to | |
| * avoid drowning the original under a dozen near-identical tiles. | |
| * | |
| * Priority: 1) official, 2) oldest (likely original), 3) most likes | |
| * as tiebreaker. | |
| */ | |
| function dedupAppsByName(apps) { | |
| const deduped = new Map(); | |
| for (const app of apps) { | |
| const key = app.name.toLowerCase(); | |
| const existing = deduped.get(key); | |
| if (!existing) { | |
| deduped.set(key, app); | |
| continue; | |
| } | |
| if (app.isOfficial && !existing.isOfficial) { | |
| deduped.set(key, app); | |
| continue; | |
| } | |
| if (existing.isOfficial) continue; | |
| 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); | |
| } | |
| } | |
| return [...deduped.values()]; | |
| } | |
| /** | |
| * Dedup applied to the full `/api/apps` payload (Python + JS + others | |
| * mixed). Same-name collisions across SDKs collapse here too, by design: | |
| * the showcase site favours a clean catalog over completeness, and | |
| * SDK-aware variants of the same idea live as separate Spaces only | |
| * by accident in practice. | |
| */ | |
| function dedupGlobalApps(apps) { | |
| return dedupAppsByName(apps); | |
| } | |
| /** | |
| * Dedup applied to the `/api/js-apps` route only. We restrict the | |
| * comparison to entries already filtered to the JS subset, so a JS | |
| * Space (e.g. `tfrere/emotions`) does not lose a name fight against | |
| * an unrelated Python Space sharing the same repo name (e.g. | |
| * `RemiFabre/emotions`). The mobile shell only sees JS apps anyway, | |
| * so confining dedup to that scope is what matches the user model. | |
| */ | |
| function dedupJsApps(jsApps) { | |
| return dedupAppsByName(jsApps); | |
| } | |
| // Get raw apps with caching. Dedup is NOT applied here - each | |
| // route owns its own dedup policy (see `dedupGlobalApps` and | |
| // `dedupJsApps`) so they can disagree without paying for two | |
| // upstream fetches. | |
| async function getRawApps() { | |
| 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} raw entries`); | |
| 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 raw = await getRawApps(); | |
| const apps = dedupGlobalApps(raw); | |
| setCatalogCacheHeaders(res, appsCache.lastFetch); | |
| res.json({ | |
| apps, | |
| cached: true, | |
| count: apps.length, | |
| }); | |
| } catch (err) { | |
| console.error('[API] Error:', err); | |
| res.status(500).json({ error: 'Failed to fetch apps' }); | |
| } | |
| }); | |
| // ===================================================================== | |
| // MCP tool catalog | |
| // ===================================================================== | |
| // | |
| // `/api/mcp-tools` is the catalog of MCP tool sources: public HF Spaces | |
| // tagged `reachy-mini-tool` that expose the standard Gradio MCP endpoint. | |
| // It mirrors `/api/apps` (cache + dedup + cache headers) and adds, for | |
| // each entry, the resolved `mcpUrl` a client needs to wire up a transport. | |
| // | |
| // There is no LLM categorization here (tools are not browsed by theme), | |
| // but the safety gate is the SAME fail-closed policy as `/api/js-apps`: | |
| // `decideVisibility` hides anything that is not an official tool or does | |
| // not carry an explicit `allow` verdict. `?includeHidden=true` bypasses | |
| // the filter for admin/debug and keeps the flags for inspection. | |
| // | |
| // Why tools get at least the app-level gate: an entry here describes a | |
| // tool the assistant may actually invoke, and whose description is fed | |
| // into the model's prompt. Unlike an app - which the user opens, watches, | |
| // and which runs sandboxed in an iframe - a tool acts through the robot | |
| // without anyone reading its description first. The blast radius is | |
| // strictly larger, so the fail-open block-list-only policy this route | |
| // started with was the wrong default. | |
| /** | |
| * Get the raw MCP tool catalog with caching. Same shape and TTL policy as | |
| * `getRawApps`: serve a warm cache, dedupe concurrent fetches, and fall | |
| * back to stale data on upstream failure rather than emptying the catalog. | |
| */ | |
| async function getRawMcpTools() { | |
| const now = Date.now(); | |
| if ( | |
| mcpToolsCache.data && | |
| mcpToolsCache.lastFetch && | |
| now - mcpToolsCache.lastFetch < CACHE_TTL_MS | |
| ) { | |
| const ageMinutes = Math.round((now - mcpToolsCache.lastFetch) / 60000); | |
| console.log(`[MCP] Returning cached tools (age: ${ageMinutes} min)`); | |
| return mcpToolsCache.data; | |
| } | |
| if (mcpToolsCache.fetching) { | |
| console.log('[MCP] Fetch already in progress, returning stale data'); | |
| return mcpToolsCache.data || []; | |
| } | |
| mcpToolsCache.fetching = true; | |
| try { | |
| console.log(`[MCP] Fetching tool Spaces tagged "${MCP_TOOL_TAG}"...`); | |
| const tools = await fetchMcpToolsFromHF({ | |
| hfSpacesApi: HF_SPACES_API, | |
| limit: HF_SPACES_LIMIT, | |
| officialListUrl: OFFICIAL_APP_LIST_URL, | |
| officialToolListUrl: OFFICIAL_TOOL_LIST_URL, | |
| blockListUrl: BLOCK_LIST_URL, | |
| }); | |
| mcpToolsCache.data = tools; | |
| mcpToolsCache.lastFetch = now; | |
| console.log(`[MCP] Cache updated with ${tools.length} tool Space(s)`); | |
| return tools; | |
| } catch (err) { | |
| if (mcpToolsCache.data) { | |
| console.log('[MCP] Fetch failed, returning stale cache'); | |
| return mcpToolsCache.data; | |
| } | |
| throw err; | |
| } finally { | |
| mcpToolsCache.fetching = false; | |
| } | |
| } | |
| app.get('/api/mcp-tools', async (req, res) => { | |
| try { | |
| const raw = await getRawMcpTools(); | |
| const deduped = dedupToolsByName(raw); | |
| // Background moderation top-up, same pattern as `/api/js-apps`: never | |
| // block the response, serve whatever the verdict cache knows now and | |
| // let the next request pick up newly cleared tools. | |
| const needsModeration = deduped.some( | |
| (t) => | |
| !t.isOfficial && | |
| moderationCache.needsModeration(t.id, t.extra?.lastModified), | |
| ); | |
| if (needsModeration) { | |
| void runModerationBatch(deduped, { label: 'MCP tool' }).catch((err) => { | |
| console.error('[Moderate] Background tool batch crashed:', err); | |
| }); | |
| } | |
| // Enforcement point: fail-closed, so a blocked, quarantined or | |
| // not-yet-moderated Space never reaches a client. | |
| // `?includeHidden=true` is the admin/debug escape. | |
| const includeHidden = req.query.includeHidden === 'true'; | |
| const tools = includeHidden | |
| ? deduped | |
| : deduped.filter((t) => computeVisibility(t).visible); | |
| setCatalogCacheHeaders(res, mcpToolsCache.lastFetch); | |
| res.json({ | |
| tools, | |
| cached: true, | |
| count: tools.length, | |
| hidden: deduped.length - tools.length, | |
| moderation: { | |
| hidden: deduped.length - tools.length, | |
| ...moderationCache.stats(), | |
| }, | |
| }); | |
| } catch (err) { | |
| console.error('[API] /api/mcp-tools error:', err); | |
| res.status(500).json({ error: 'Failed to fetch MCP tools' }); | |
| } | |
| }); | |
| // Manual trigger for a tool moderation sweep (twin of | |
| // `/api/js-apps/refresh-moderation`). Useful after editing | |
| // `config/official-tool-list.json` or bumping the moderation policy. | |
| app.post('/api/mcp-tools/refresh-moderation', async (req, res) => { | |
| try { | |
| const tools = dedupToolsByName(await getRawMcpTools()); | |
| void runModerationBatch(tools, { label: 'MCP tool' }).catch((err) => { | |
| console.error('[Moderate] Manual tool batch crashed:', err); | |
| }); | |
| res.json({ | |
| ok: true, | |
| message: `Moderation batch kicked off for ${tools.length} MCP tool(s).`, | |
| stats: moderationCache.stats(), | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ error: 'Failed to trigger moderation refresh' }); | |
| } | |
| }); | |
| // ===================================================================== | |
| // JS apps + LLM-inferred categories | |
| // ===================================================================== | |
| // | |
| // `/api/js-apps` is a curated view on the JS-only subset: | |
| // 1. Filter on the `reachy_mini_js_app` tag (the mobile-embeddable subset). | |
| // 2. Dedup name collisions among JS apps only (`dedupJsApps`), | |
| // so a JS app does not get knocked out by a same-named Python | |
| // Space surfaced through `/api/apps`. | |
| // 3. Enrich each entry with `categories` + `categories_source`, | |
| // sourced from a persistent dataset cache (see categoryCache.js). | |
| // | |
| // Categories are inferred lazily by an LLM from each Space's | |
| // README. The first request after a cold start may see entries | |
| // with `categories: null` while the warmup batch is still in | |
| // flight; subsequent requests pick them up as the cache fills. | |
| /** | |
| * Pull the JS-app subset out of the raw apps cache, dedup it | |
| * within the JS scope, and fold in cached categories. Pure, | |
| * synchronous-ish (the only async call is to `getRawApps()` which | |
| * has its own cache). | |
| */ | |
| async function getJsApps() { | |
| const raw = await getRawApps(); | |
| const jsApps = raw.filter((a) => { | |
| const tags = a?.extra?.tags; | |
| return Array.isArray(tags) && tags.includes(JS_APP_TAG); | |
| }); | |
| const deduped = dedupJsApps(jsApps); | |
| return deduped.map((app) => { | |
| const cached = categoryCache.get(app.id); | |
| const moderation = computeVisibility(app); | |
| return { | |
| ...app, | |
| categories: cached ? cached.categories : null, | |
| categories_source: cached ? 'inferred' : null, | |
| categorized_at: cached ? cached.categorizedAt : null, | |
| mobile_visible: moderation.visible, | |
| moderation, | |
| }; | |
| }); | |
| } | |
| /** | |
| * Decide whether a catalog entry is visible to clients, and why. | |
| * | |
| * Shared by `/api/js-apps` and `/api/mcp-tools`: both are fail-closed and | |
| * both key verdicts by Space ID, so there is exactly one enforcement | |
| * helper rather than one per catalog. | |
| * | |
| * Thin wrapper: looks up the cached moderation verdict and delegates | |
| * the fail-closed policy to the pure `decideVisibility` in | |
| * `visibility.js` (which is unit-tested in isolation - see | |
| * `test/visibility.test.mjs`). Only an explicit `allow` verdict (or a | |
| * curated official entry) is visible; a `block`, a `review`, a manual | |
| * block-list hit, or no verdict yet all keep it hidden, so a new | |
| * Space never appears before moderation has explicitly cleared it | |
| * (App Store guideline 1.2). | |
| * | |
| * Returns `{ visible, source, decision, category, reason }` so the | |
| * payload can explain a hide to the website / admins without leaking | |
| * a blocked entry's content. | |
| */ | |
| function computeVisibility(app) { | |
| return decideVisibility(app, moderationCache.get(app.id)); | |
| } | |
| /** | |
| * Run one moderation pass over `entries`. Mirrors | |
| * `runCategorizationBatch`: serial, skips official entries and those | |
| * whose verdict is still fresh, jitters between LLM calls, persists | |
| * once at the end. Never throws (transient misses are retried next | |
| * pass). | |
| * | |
| * Catalog-agnostic: it only needs `{ id, name, description, isOfficial, | |
| * extra.lastModified }`, which both JS apps and MCP tool Spaces expose. | |
| * `label` is cosmetic, for readable logs. Verdicts are keyed by Space ID | |
| * in a single shared cache, so a Space that is both an app and a tool is | |
| * moderated once. | |
| * | |
| * The `moderationBatchRunning` guard is global on purpose: app and tool | |
| * sweeps share the HF Inference token, so they must not burst together. | |
| * A sweep skipped for that reason is retried on the next request. | |
| */ | |
| async function runModerationBatch(entries, { label = 'JS app' } = {}) { | |
| if (moderationBatchRunning) { | |
| console.log('[Moderate] Batch already running, skipping.'); | |
| return; | |
| } | |
| if (!process.env.HF_TOKEN) { | |
| console.warn( | |
| '[Moderate] HF_TOKEN not set; skipping batch. Set it in .env or the Space secrets to enable moderation.', | |
| ); | |
| return; | |
| } | |
| const todo = entries.filter( | |
| (entry) => | |
| !entry.isOfficial && | |
| moderationCache.needsModeration(entry.id, entry?.extra?.lastModified), | |
| ); | |
| if (todo.length === 0) { | |
| console.log( | |
| `[Moderate] All ${entries.length} ${label}(s) already moderated.`, | |
| ); | |
| return; | |
| } | |
| moderationBatchRunning = true; | |
| console.log( | |
| `[Moderate] Starting batch: ${todo.length}/${entries.length} ${label}(s) need moderation.`, | |
| ); | |
| let blocked = 0; | |
| let allowed = 0; | |
| let failed = 0; | |
| let aborted = false; | |
| for (let i = 0; i < todo.length; i++) { | |
| const entry = todo[i]; | |
| const desc = | |
| entry.description || entry.extra?.cardData?.short_description || ''; | |
| try { | |
| const verdict = await moderateApp({ | |
| spaceId: entry.id, | |
| name: entry.name, | |
| description: desc, | |
| }); | |
| if (verdict == null) { | |
| failed++; | |
| } else { | |
| moderationCache.set(entry.id, { | |
| ...verdict, | |
| lastModified: entry.extra?.lastModified || null, | |
| }); | |
| if (verdict.decision === 'block') blocked++; | |
| else allowed++; | |
| console.log( | |
| `[Moderate] (${i + 1}/${todo.length}) ${entry.id}: ${verdict.decision}/${verdict.category} (${verdict.source})`, | |
| ); | |
| } | |
| } catch (err) { | |
| if (err instanceof HfTokenMissingError) { | |
| console.warn('[Moderate] HF_TOKEN missing mid-batch; aborting.'); | |
| aborted = true; | |
| break; | |
| } | |
| failed++; | |
| console.warn(`[Moderate] (${i + 1}/${todo.length}) ${entry.id}: error - ${err.message}`); | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, 250)); | |
| } | |
| console.log( | |
| `[Moderate] Batch done: ${allowed} allowed, ${blocked} blocked, ${failed} failed${aborted ? ' (aborted)' : ''}.`, | |
| ); | |
| await moderationCache.flush(); | |
| moderationBatchRunning = false; | |
| } | |
| /** | |
| * Run one classification pass over `jsApps`. Skips entries whose | |
| * cache is still fresh (same `lastModified`, same taxonomy). | |
| * | |
| * Serial on purpose: HF Inference Providers don't love bursts | |
| * from a single token, and total throughput on ~50 apps stays | |
| * well under a minute. We slip a small jitter between calls to | |
| * smooth the curve further. | |
| */ | |
| async function runCategorizationBatch(jsApps) { | |
| if (categorizationBatchRunning) { | |
| console.log('[Categorize] Batch already running, skipping.'); | |
| return; | |
| } | |
| if (!process.env.HF_TOKEN) { | |
| console.warn( | |
| '[Categorize] HF_TOKEN not set; skipping batch. Set it in .env ' + | |
| 'or the Space secrets to enable category inference.', | |
| ); | |
| return; | |
| } | |
| const todo = jsApps.filter((app) => | |
| categoryCache.needsCategorization(app.id, app?.extra?.lastModified), | |
| ); | |
| if (todo.length === 0) { | |
| console.log( | |
| `[Categorize] All ${jsApps.length} JS apps are already categorized.`, | |
| ); | |
| return; | |
| } | |
| categorizationBatchRunning = true; | |
| console.log( | |
| `[Categorize] Starting batch: ${todo.length}/${jsApps.length} app(s) need classification.`, | |
| ); | |
| let success = 0; | |
| let failed = 0; | |
| let aborted = false; | |
| for (let i = 0; i < todo.length; i++) { | |
| const app = todo[i]; | |
| const desc = | |
| app.description || | |
| app.extra?.cardData?.short_description || | |
| ''; | |
| try { | |
| const slugs = await categorizeApp({ | |
| spaceId: app.id, | |
| name: app.name, | |
| description: desc, | |
| }); | |
| if (slugs == null) { | |
| failed++; | |
| console.log( | |
| `[Categorize] (${i + 1}/${todo.length}) ${app.id}: transient failure, will retry next pass`, | |
| ); | |
| } else { | |
| categoryCache.set(app.id, { | |
| categories: slugs, | |
| lastModified: app.extra?.lastModified || null, | |
| }); | |
| success++; | |
| console.log( | |
| `[Categorize] (${i + 1}/${todo.length}) ${app.id}: ${ | |
| slugs.length ? slugs.join(', ') : '(no fit)' | |
| }`, | |
| ); | |
| } | |
| } catch (err) { | |
| if (err instanceof HfTokenMissingError) { | |
| console.warn( | |
| '[Categorize] HF_TOKEN missing mid-batch; aborting cleanly.', | |
| ); | |
| aborted = true; | |
| break; | |
| } | |
| failed++; | |
| console.warn( | |
| `[Categorize] (${i + 1}/${todo.length}) ${app.id}: error - ${err.message}`, | |
| ); | |
| } | |
| // 250 ms cooldown between calls. Below this, the HF Provider | |
| // router occasionally rate-limits a hot token. | |
| await new Promise((resolve) => setTimeout(resolve, 250)); | |
| } | |
| console.log( | |
| `[Categorize] Batch done: ${success} ok, ${failed} failed${aborted ? ' (aborted)' : ''}.`, | |
| ); | |
| // Persist the new entries even if some failed - partial | |
| // progress is strictly better than none, and the failed | |
| // entries will be retried on the next pass. | |
| await categoryCache.flush(); | |
| categorizationBatchRunning = false; | |
| } | |
| /** | |
| * Wrap the diagnostic snapshot for the API payload. Lets | |
| * consumers (mobile shell, website) decide whether to show | |
| * "loading categories..." or render chips immediately. | |
| */ | |
| function buildCategorizationStats(jsApps) { | |
| let withCategories = 0; | |
| for (const app of jsApps) { | |
| if (app.categories && app.categories.length >= 0 && app.categories_source) { | |
| withCategories++; | |
| } | |
| } | |
| return { | |
| enabled: !!process.env.HF_TOKEN, | |
| total: jsApps.length, | |
| classified: withCategories, | |
| pending: jsApps.length - withCategories, | |
| inProgress: categorizationBatchRunning, | |
| // Authoritative taxonomy shipped alongside the apps so the | |
| // mobile shell (and any future client) doesn't have to mirror | |
| // the slug list by hand. Pairs with `taxonomyVersion` from | |
| // `categoryCache.stats()` so clients can detect drift between | |
| // the catalog payload and a stale on-device cache. | |
| taxonomy: getPublicTaxonomy(), | |
| ...categoryCache.stats(), | |
| }; | |
| } | |
| app.get('/api/js-apps', async (req, res) => { | |
| try { | |
| const apps = await getJsApps(); | |
| // Background top-up for categorization: if any entry is still | |
| // uncategorized (or a Space's lastModified moved), fire a batch. | |
| // Not awaited - the response goes out with whatever the cache | |
| // currently knows. | |
| const needsCategories = apps.some( | |
| (a) => | |
| !a.categories_source || | |
| categoryCache.needsCategorization(a.id, a.extra?.lastModified), | |
| ); | |
| if (needsCategories) { | |
| void runCategorizationBatch(apps).catch((err) => { | |
| console.error('[Categorize] Background batch crashed:', err); | |
| }); | |
| } | |
| // Same pattern for moderation: top-up in the background, never | |
| // block the response. | |
| const needsModeration = apps.some( | |
| (a) => | |
| !a.isOfficial && | |
| moderationCache.needsModeration(a.id, a.extra?.lastModified), | |
| ); | |
| if (needsModeration) { | |
| void runModerationBatch(apps).catch((err) => { | |
| console.error('[Moderate] Background batch crashed:', err); | |
| }); | |
| } | |
| // Filter out everything not visible in the mobile catalog. This | |
| // is the enforcement point: blocked / hidden apps never reach any | |
| // client (mobile or website). `?includeHidden=true` bypasses the | |
| // filter for admin / debugging (the hidden entries keep their | |
| // `moderation` field explaining why). | |
| const includeHidden = req.query.includeHidden === 'true'; | |
| const visibleApps = includeHidden | |
| ? apps | |
| : apps.filter((a) => a.mobile_visible !== false); | |
| setCatalogCacheHeaders(res, appsCache.lastFetch); | |
| res.json({ | |
| apps: visibleApps, | |
| cached: true, | |
| count: visibleApps.length, | |
| categorization: buildCategorizationStats(visibleApps), | |
| moderation: { | |
| hidden: apps.length - visibleApps.length, | |
| ...moderationCache.stats(), | |
| }, | |
| }); | |
| } catch (err) { | |
| console.error('[API] /api/js-apps error:', err); | |
| res.status(500).json({ error: 'Failed to fetch JS apps' }); | |
| } | |
| }); | |
| // ===================================================================== | |
| // Public taxonomy endpoint | |
| // ===================================================================== | |
| // | |
| // Standalone read-only projection of the closed category taxonomy | |
| // (`server/categories.js`). Lets clients consume the slug list, | |
| // labels and emojis without paying the cost of a full apps fetch - | |
| // useful for early UI scaffolding (filter chips, empty states) and | |
| // for tooling that lints app metadata against the live taxonomy. | |
| // | |
| // `/api/js-apps` ALSO embeds the same payload under | |
| // `categorization.taxonomy`, so a mobile shell that fetches the | |
| // catalog never needs a second round-trip. This endpoint exists | |
| // for the "I just want the categories" use case. | |
| // | |
| // Cache headers: 5 minutes, same TTL as the catalog. The taxonomy | |
| // is stable across many catalog refreshes (it only moves when we | |
| // bump `TAXONOMY_VERSION`), but co-aligning the TTLs keeps the | |
| // reasoning simple - a client that polls both gets a coherent view. | |
| app.get('/api/categories', (_req, res) => { | |
| res.set('Cache-Control', 'public, max-age=300'); | |
| const stats = categoryCache.stats(); | |
| res.json({ | |
| taxonomy: getPublicTaxonomy(), | |
| taxonomyVersion: stats.taxonomyVersion, | |
| }); | |
| }); | |
| // Manual trigger for a categorization sweep, useful when | |
| // hand-tuning the taxonomy or testing the LLM prompt without | |
| // waiting for the next /api/js-apps hit. | |
| app.post('/api/js-apps/refresh-categories', async (req, res) => { | |
| try { | |
| const apps = await getJsApps(); | |
| void runCategorizationBatch(apps).catch((err) => { | |
| console.error('[Categorize] Manual batch crashed:', err); | |
| }); | |
| res.json({ | |
| ok: true, | |
| message: `Categorization batch kicked off for ${apps.length} JS apps.`, | |
| stats: buildCategorizationStats(apps), | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ error: 'Failed to trigger refresh' }); | |
| } | |
| }); | |
| // Manual trigger for a moderation sweep (twin of refresh-categories). | |
| app.post('/api/js-apps/refresh-moderation', async (req, res) => { | |
| try { | |
| const apps = await getJsApps(); | |
| void runModerationBatch(apps).catch((err) => { | |
| console.error('[Moderate] Manual batch crashed:', err); | |
| }); | |
| res.json({ | |
| ok: true, | |
| message: `Moderation batch kicked off for ${apps.length} JS apps.`, | |
| stats: moderationCache.stats(), | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ error: 'Failed to trigger moderation refresh' }); | |
| } | |
| }); | |
| // ===================================================================== | |
| // OpenAI Realtime ephemeral keys | |
| // ===================================================================== | |
| // | |
| // Per-user mint endpoint backing the Reachy Mini mobile shell's | |
| // voice conversation. The mobile client posts its HF Bearer token, | |
| // we validate it via `whoami-v2`, rate-limit per HF user, and | |
| // proxy a `POST /v1/realtime/sessions` to OpenAI with the master | |
| // `OPENAI_API_KEY` from this Space's secrets. The short-lived | |
| // `client_secret.value` is forwarded back to the client. | |
| // | |
| // See `server/openaiEphemeral.js` for the full design notes | |
| // (auth, caching, rate-limit shape, error mapping). | |
| app.post('/api/openai/ephemeral', mintEphemeralKeyHandler); | |
| // 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, | |
| mcpToolsCacheStatus: mcpToolsCache.data ? 'warm' : 'cold', | |
| mcpToolsCount: mcpToolsCache.data?.length || 0, | |
| }); | |
| }); | |
| // Force cache refresh (for admin use) | |
| app.post('/api/refresh', async (req, res) => { | |
| try { | |
| appsCache.lastFetch = null; // Invalidate cache | |
| mcpToolsCache.lastFetch = null; // Invalidate MCP tool cache too | |
| const [apps, mcpTools] = await Promise.all([ | |
| getRawApps(), | |
| getRawMcpTools().catch(() => []), | |
| ]); | |
| res.json({ success: true, count: apps.length, mcpToolsCount: mcpTools.length }); | |
| } catch (err) { | |
| res.status(500).json({ error: 'Failed to refresh cache' }); | |
| } | |
| }); | |
| // API-only service: the showcase website is a separate static Space | |
| // now, so there is no `dist/` to serve here. Any non-API path is a | |
| // client error - return JSON rather than HTML so callers (mobile | |
| // shell, website fetch) always get a parseable body. | |
| app.use((req, res) => { | |
| res.status(404).json({ error: 'not found', path: req.path }); | |
| }); | |
| // Pre-warm cache on startup | |
| async function warmCache() { | |
| console.log('[Startup] Pre-warming cache...'); | |
| // MCP tool catalog warm-up: fire-and-forget so a slow tool listing | |
| // never delays the (more critical) app catalog warm-up below. Its own | |
| // cache + stale-fallback make a transient failure here harmless. | |
| void getRawMcpTools() | |
| .then((tools) => | |
| console.log(`[Startup] MCP tool cache warmed (${tools.length} Space(s))`), | |
| ) | |
| .catch((err) => console.error('[Startup] MCP tool warm-up failed:', err)); | |
| try { | |
| const apps = await getRawApps(); | |
| console.log('[Startup] Cache warmed successfully'); | |
| // Categorization warm-up: fire the JS-app batch in the | |
| // background so the first /api/js-apps caller doesn't | |
| // shoulder the cold-start cost. Order: load the dataset | |
| // cache first (cheap, one HTTP call), then run the batch | |
| // for stale entries only. | |
| void (async () => { | |
| try { | |
| // Load the editable taxonomy from the dataset FIRST so the | |
| // category cache prunes stale entries against the live | |
| // version (and the LLM prompt uses the live descriptions). | |
| await loadTaxonomyFromDataset(STORE_DATASET, process.env.HF_TOKEN); | |
| await Promise.all([categoryCache.load(), moderationCache.load()]); | |
| const jsApps = dedupJsApps( | |
| apps.filter((a) => { | |
| const tags = a?.extra?.tags; | |
| return Array.isArray(tags) && tags.includes(JS_APP_TAG); | |
| }), | |
| ); | |
| console.log( | |
| `[Startup] Found ${jsApps.length} JS apps; checking categories + moderation...`, | |
| ); | |
| // Moderation first, then categories - they share the HF | |
| // Inference token, so running them serially avoids doubling | |
| // the burst on a cold start. Since the catalog is fail-closed | |
| // (non-official apps are hidden until an explicit `allow` | |
| // verdict lands), moderation is the visibility gate and runs | |
| // first so cleared apps surface as soon as possible; categories | |
| // are cosmetic and can fill in a few seconds later. | |
| await runModerationBatch(jsApps); | |
| // Same gate for the MCP tool catalog, and it must run after | |
| // `moderationCache.load()` above: sweeping before the persistent | |
| // verdicts are in memory would re-moderate every tool on each | |
| // cold start. Isolated so a tool-catalog failure still leaves the | |
| // app categorization below to run. | |
| try { | |
| const tools = dedupToolsByName(await getRawMcpTools()); | |
| console.log( | |
| `[Startup] Found ${tools.length} MCP tool Space(s); checking moderation...`, | |
| ); | |
| await runModerationBatch(tools, { label: 'MCP tool' }); | |
| } catch (err) { | |
| console.error('[Startup] MCP tool moderation warm-up failed:', err); | |
| } | |
| await runCategorizationBatch(jsApps); | |
| } catch (err) { | |
| console.error('[Startup] Categorization/moderation warm-up failed:', err); | |
| } | |
| })(); | |
| } catch (err) { | |
| console.error('[Startup] Failed to warm cache:', err); | |
| } | |
| } | |
| // Start server | |
| app.listen(PORT, () => { | |
| console.log(`[Server] Running on port ${PORT}`); | |
| warmCache(); | |
| }); | |