diff --git a/apis/briefing.mjs b/apis/briefing.mjs index 4b916ba..5e3e8e4 100644 --- a/apis/briefing.mjs +++ b/apis/briefing.mjs @@ -43,10 +43,16 @@ import { briefing as space } from './sources/space.mjs'; // === Tier 5: Live Market Data === import { briefing as yfinance } from './sources/yfinance.mjs'; +const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source + export async function runSource(name, fn, ...args) { const start = Date.now(); try { - const data = await fn(...args); + const dataPromise = fn(...args); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`Source ${name} timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS) + ); + const data = await Promise.race([dataPromise, timeoutPromise]); return { name, status: 'ok', durationMs: Date.now() - start, data }; } catch (e) { return { name, status: 'error', durationMs: Date.now() - start, error: e.message }; @@ -57,7 +63,7 @@ export async function fullBriefing() { console.error('[Crucix] Starting intelligence sweep — 27 sources...'); const start = Date.now(); - const results = await Promise.allSettled([ + const allPromises = [ // Tier 1: Core OSINT & Geopolitical runSource('GDELT', gdelt), runSource('OpenSky', opensky), @@ -94,7 +100,11 @@ export async function fullBriefing() { // Tier 5: Live Market Data runSource('YFinance', yfinance), - ]); + ]; + + // Each runSource has its own 30s timeout, so allSettled will resolve + // within ~30s even if APIs hang. Global timeout is a safety net. + const results = await Promise.allSettled(allPromises); const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message }); const totalMs = Date.now() - start; diff --git a/apis/sources/bls.mjs b/apis/sources/bls.mjs index 85195fb..86bfdbd 100644 --- a/apis/sources/bls.mjs +++ b/apis/sources/bls.mjs @@ -37,11 +37,15 @@ export async function getSeries(seriesIds, opts = {}) { if (apiKey) payload.registrationkey = apiKey; try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); const res = await fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + signal: controller.signal, }); + clearTimeout(timer); return await res.json(); } catch (e) { return { error: e.message }; diff --git a/dashboard/public/loading.html b/dashboard/public/loading.html index 196f6ab..fe9f998 100644 --- a/dashboard/public/loading.html +++ b/dashboard/public/loading.html @@ -130,18 +130,26 @@ fetch('/api/health') .catch(() => startCountdown(0)); // === SSE — wait for sweep to complete, then redirect === +let redirected = false; +function goToDashboard() { + if (redirected) return; + redirected = true; + clearInterval(countdownInterval); + clearInterval(pollInterval); + barFill.style.transition = 'width 0.4s ease'; + barFill.style.width = '100%'; + etaText.textContent = ''; + statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD'; + setTimeout(() => location.replace('/'), 800); +} + const es = new EventSource('/events'); es.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.type === 'update') { es.close(); - clearInterval(countdownInterval); - barFill.style.transition = 'width 0.4s ease'; - barFill.style.width = '100%'; - etaText.textContent = ''; - statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD'; - setTimeout(() => location.replace('/'), 800); + goToDashboard(); } } catch {} }; @@ -149,6 +157,13 @@ es.onerror = () => { es.close(); setTimeout(() => location.reload(), 3000); }; + +// === Fallback polling — in case SSE misses the update === +const pollInterval = setInterval(() => { + fetch('/api/data').then(r => { + if (r.ok) goToDashboard(); + }).catch(() => {}); +}, 5000); diff --git a/server.mjs b/server.mjs index 86e26b5..b64995b 100644 --- a/server.mjs +++ b/server.mjs @@ -408,7 +408,7 @@ async function start() { process.exit(1); }); - server.on('listening', () => { + server.on('listening', async () => { console.log(`[Crucix] Server running on http://localhost:${port}`); // Auto-open browser @@ -420,17 +420,18 @@ async function start() { if (err) console.log('[Crucix] Could not auto-open browser:', err.message); }); - // Try to load existing data first for instant display + // Try to load existing data first for instant display (await so dashboard shows immediately) try { const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8')); - synthesize(existing).then(data => { - currentData = data; - console.log('[Crucix] Loaded existing data from runs/latest.json'); - broadcast({ type: 'update', data: currentData }); - }).catch(() => {}); - } catch { /* no existing data */ } + const data = await synthesize(existing); + currentData = data; + console.log('[Crucix] Loaded existing data from runs/latest.json — dashboard ready instantly'); + broadcast({ type: 'update', data: currentData }); + } catch { + console.log('[Crucix] No existing data found — first sweep required'); + } - // Run first sweep + // Run first sweep (refreshes data in background) console.log('[Crucix] Running initial sweep...'); runSweepCycle().catch(err => { console.error('[Crucix] Initial sweep failed:', err.message || err);