Files
worldmonitor/api/bootstrap.js
Elie Habib 354c0df1fe feat(tech-events): gold standard pipeline with Railway seed + bootstrap hydration (#1475)
* fix(tech-events): prevent partial fetch results from being cached

Techmeme ICS and dev.events RSS fetches on Vercel edge can partially
fail (timeout, truncation), returning only 1 event instead of 20+.
The handler cached this partial result for 6 hours, causing the Tech
Events panel to show empty.

- Add 8s AbortSignal.timeout on both external fetches
- Require minimum 5 events before caching (at least curated count)

* fix(tech-events): remove MIN_EVENTS threshold and add diagnostic logging

The MIN_EVENTS=5 threshold caused empty results when both external
sources fail on Vercel edge (only 4 curated events available). Now
any events > 0 are cached. Added detailed logging to diagnose why
Techmeme ICS and dev.events RSS fetches fail on Vercel edge.
Also removed past STEP Dubai 2026 event.

* fix(tech-events): route fetches through Railway relay when direct fails

Vercel edge functions cannot reliably reach Techmeme ICS and dev.events
RSS (datacenter IP blocking). Added fetchTextWithRelay() that tries
direct fetch first, then falls back to Railway relay proxy (/rss endpoint)
which fetches from a different IP. Same pattern used by news feed digest
and other handlers that hit blocked external sources.

* feat(tech-events): gold standard pipeline with Railway seed + bootstrap hydration

Full data pipeline overhaul to match project conventions:

- Add tech events seed loop to ais-relay.cjs: fetches Techmeme ICS +
  dev.events RSS every 6h from Railway (avoids Vercel IP blocking),
  parses both sources, merges with curated fallback events, writes to
  Redis (data key + bootstrap key + seed-meta)
- Register in api/bootstrap.js BOOTSTRAP_CACHE_KEYS (SLOW tier)
- Register in api/health.js BOOTSTRAP_KEYS + SEED_META (420min stale)
- Restructure RPC handler: reads from single broad Redis key (populated
  by seed), applies geocoding + filtering in-memory per request params.
  Fallback fetcher only runs on cold start before first seed
- TechEventsPanel: check getHydratedData('techEvents') from bootstrap
  before falling back to RPC call
- data-loader: use hydrated bootstrap data for map layer, RPC fallback
2026-03-12 08:18:59 +04:00

165 lines
6.0 KiB
JavaScript

import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { validateApiKey } from './_api-key.js';
export const config = { runtime: 'edge' };
const BOOTSTRAP_CACHE_KEYS = {
earthquakes: 'seismology:earthquakes:v1',
outages: 'infra:outages:v1',
serviceStatuses: 'infra:service-statuses:v1',
marketQuotes: 'market:stocks-bootstrap:v1',
commodityQuotes: 'market:commodities-bootstrap:v1',
sectors: 'market:sectors:v1',
etfFlows: 'market:etf-flows:v1',
macroSignals: 'economic:macro-signals:v1',
bisPolicy: 'economic:bis:policy:v1',
bisExchange: 'economic:bis:eer:v1',
bisCredit: 'economic:bis:credit:v1',
shippingRates: 'supply_chain:shipping:v2',
chokepoints: 'supply_chain:chokepoints:v2',
minerals: 'supply_chain:minerals:v2',
giving: 'giving:summary:v1',
climateAnomalies: 'climate:anomalies:v1',
wildfires: 'wildfire:fires:v1',
cyberThreats: 'cyber:threats-bootstrap:v2',
techReadiness: 'economic:worldbank-techreadiness:v1',
progressData: 'economic:worldbank-progress:v1',
renewableEnergy: 'economic:worldbank-renewable:v1',
positiveGeoEvents: 'positive-events:geo-bootstrap:v1',
theaterPosture: 'theater-posture:sebuf:stale:v1',
riskScores: 'risk:scores:sebuf:stale:v1',
naturalEvents: 'natural:events:v1',
flightDelays: 'aviation:delays-bootstrap:v1',
insights: 'news:insights:v1',
predictions: 'prediction:markets-bootstrap:v1',
cryptoQuotes: 'market:crypto:v1',
gulfQuotes: 'market:gulf-quotes:v1',
stablecoinMarkets: 'market:stablecoins:v1',
unrestEvents: 'unrest:events:v1',
iranEvents: 'conflict:iran-events:v1',
ucdpEvents: 'conflict:ucdp-events:v1',
temporalAnomalies: 'temporal:anomalies:v1',
weatherAlerts: 'weather:alerts:v1',
spending: 'economic:spending:v1',
techEvents: 'research:tech-events-bootstrap:v1',
};
const SLOW_KEYS = new Set([
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
'sectors', 'etfFlows', 'shippingRates', 'wildfires', 'climateAnomalies',
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
'naturalEvents',
'cryptoQuotes', 'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents',
'techEvents',
]);
const FAST_KEYS = new Set([
'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints',
'marketQuotes', 'commodityQuotes', 'positiveGeoEvents', 'riskScores', 'flightDelays','insights', 'predictions',
'iranEvents', 'temporalAnomalies', 'weatherAlerts', 'spending', 'theaterPosture',
]);
const TIER_CACHE = {
slow: 'public, s-maxage=3600, stale-while-revalidate=600, stale-if-error=3600',
fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900',
};
const TIER_CDN_CACHE = {
slow: 'public, s-maxage=7200, stale-while-revalidate=1800, stale-if-error=7200',
fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900',
};
const NEG_SENTINEL = '__WM_NEG__';
async function getCachedJsonBatch(keys) {
const result = new Map();
if (keys.length === 0) return result;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return result;
// Always read unprefixed keys — bootstrap is a read-only consumer of
// production cache data. Preview/branch deploys don't run handlers that
// populate prefixed keys, so prefixing would always miss.
const pipeline = keys.map((k) => ['GET', k]);
const resp = await fetch(`${url}/pipeline`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(pipeline),
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) return result;
const data = await resp.json();
for (let i = 0; i < keys.length; i++) {
const raw = data[i]?.result;
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed !== NEG_SENTINEL) result.set(keys[i], parsed);
} catch { /* skip malformed */ }
}
}
return result;
}
export default async function handler(req) {
if (isDisallowedOrigin(req))
return new Response('Forbidden', { status: 403 });
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS')
return new Response(null, { status: 204, headers: cors });
const apiKeyResult = validateApiKey(req);
if (apiKeyResult.required && !apiKeyResult.valid)
return new Response(JSON.stringify({ error: apiKeyResult.error }), {
status: 401, headers: { ...cors, 'Content-Type': 'application/json' },
});
const url = new URL(req.url);
const tier = url.searchParams.get('tier');
let registry;
if (tier === 'slow' || tier === 'fast') {
const tierSet = tier === 'slow' ? SLOW_KEYS : FAST_KEYS;
registry = Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => tierSet.has(k)));
} else {
const requested = url.searchParams.get('keys')?.split(',').filter(Boolean).sort();
registry = requested
? Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => requested.includes(k)))
: BOOTSTRAP_CACHE_KEYS;
}
const keys = Object.values(registry);
const names = Object.keys(registry);
let cached;
try {
cached = await getCachedJsonBatch(keys);
} catch {
return new Response(JSON.stringify({ data: {}, missing: names }), {
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
});
}
const data = {};
const missing = [];
for (let i = 0; i < names.length; i++) {
const val = cached.get(keys[i]);
if (val !== undefined) data[names[i]] = val;
else missing.push(names[i]);
}
const cacheControl = (tier && TIER_CACHE[tier]) || 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900';
return new Response(JSON.stringify({ data, missing }), {
status: 200,
headers: {
...cors,
'Content-Type': 'application/json',
'Cache-Control': cacheControl,
'CDN-Cache-Control': (tier && TIER_CDN_CACHE[tier]) || TIER_CDN_CACHE.fast,
},
});
}