mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-13 10:36:21 +02:00
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.
163 lines
6.0 KiB
JavaScript
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,
|
|
},
|
|
});
|
|
}
|