Files
worldmonitor/api/bootstrap.js
Elie Habib 0b3762f55e fix(cache): align CDN and client cache TTLs with freshness thresholds (#1320)
Move theaterPosture from SLOW (2h CDN) to FAST tier (20min/10min after
PR #1314) so military posture data stays fresh. Increase risk scores
breaker TTL to 30min to match health.js maxStaleMin, and reduce
localStorage staleness from 24h to 1h to prevent stale risk data in UI.
2026-03-09 10:08:39 +04:00

163 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',
};
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',
]);
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,
},
});
}