Files
worldmonitor/api/acled.js
Elie Habib c353cf2070 Reduce egress costs, add PWA support, fix Polymarket and Railway relay
Egress optimization:
- Add s-maxage + stale-while-revalidate to all API endpoints for Vercel CDN caching
- Add vercel.json with immutable caching for hashed assets
- Add gzip compression to sidecar responses >1KB
- Add gzip to Railway RSS responses (4 paths previously uncompressed)
- Increase polling intervals: markets/crypto 60s→120s, ETF/macro/stablecoins 60s→180s
- Remove hardcoded Railway URL from theater-posture.js (now env-var only)

PWA / Service Worker:
- Add vite-plugin-pwa with autoUpdate strategy
- Cache map tiles (CacheFirst), fonts (StaleWhileRevalidate), static assets
- NetworkOnly for all /api/* routes (real-time data must be fresh)
- Manual SW registration (web only, skip Tauri)
- Add offline fallback page
- Replace manual manifest with plugin-generated manifest

Polymarket fix:
- Route dev proxy through production Vercel (bypasses JA3 blocking)
- Add 4th fallback tier: production URL as absolute fallback

Desktop/Sidecar:
- Dual-backend cache (_upstash-cache.js): Redis cloud + in-memory+file desktop
- Settings window OK/Cancel redesign
- Runtime config and secret injection improvements
2026-02-14 19:53:04 +04:00

201 lines
5.6 KiB
JavaScript

// ACLED API proxy - keeps token server-side only
// Token is stored in ACLED_ACCESS_TOKEN (no VITE_ prefix)
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
export const config = { runtime: 'edge' };
const CACHE_KEY = 'acled:protests:v2';
const CACHE_TTL_SECONDS = 10 * 60;
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
// In-memory fallback cache when Redis is unavailable.
let fallbackCache = { data: null, timestamp: 0 };
const RATE_LIMIT = 10; // requests per minute
const RATE_WINDOW_MS = 60 * 1000;
const rateLimiter = createIpRateLimiter({
limit: RATE_LIMIT,
windowMs: RATE_WINDOW_MS,
maxEntries: 5000,
});
function getClientIp(req) {
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed', data: [] }, {
status: 405,
headers: corsHeaders,
});
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed', data: [] }, {
status: 403,
headers: corsHeaders,
});
}
const ip = getClientIp(req);
if (!rateLimiter.check(ip)) {
return Response.json({ error: 'Rate limited', data: [] }, {
status: 429,
headers: {
...corsHeaders,
'Retry-After': '60',
},
});
}
const token = process.env.ACLED_ACCESS_TOKEN;
if (!token) {
return Response.json({
error: 'ACLED not configured',
data: [],
configured: false,
}, {
status: 200,
headers: corsHeaders,
});
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (cached && typeof cached === 'object' && Array.isArray(cached.data)) {
recordCacheTelemetry('/api/acled', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'REDIS-HIT',
},
});
}
if (fallbackCache.data && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/acled', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'MEMORY-HIT',
},
});
}
try {
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const params = new URLSearchParams({
event_type: 'Protests',
event_date: `${startDate}|${endDate}`,
event_date_where: 'BETWEEN',
limit: '500',
_format: 'json',
});
const response = await fetch(`https://acleddata.com/api/acled/read?${params}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const text = await response.text();
return Response.json({
error: `ACLED API error: ${response.status}`,
details: text.substring(0, 200),
data: [],
}, {
status: response.status,
headers: corsHeaders,
});
}
const rawData = await response.json();
const events = Array.isArray(rawData?.data) ? rawData.data : [];
const sanitizedEvents = events.map((e) => ({
event_id_cnty: e.event_id_cnty,
event_date: e.event_date,
event_type: e.event_type,
sub_event_type: e.sub_event_type,
actor1: e.actor1,
actor2: e.actor2,
country: e.country,
admin1: e.admin1,
location: e.location,
latitude: e.latitude,
longitude: e.longitude,
fatalities: e.fatalities,
notes: typeof e.notes === 'string' ? e.notes.substring(0, 500) : undefined,
source: e.source,
tags: e.tags,
}));
const result = {
success: true,
count: sanitizedEvents.length,
data: sanitizedEvents,
cached_at: new Date().toISOString(),
};
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/acled', 'MISS');
return Response.json(result, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'MISS',
},
});
} catch (error) {
if (fallbackCache.data) {
recordCacheTelemetry('/api/acled', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
'X-Cache': 'STALE',
},
});
}
recordCacheTelemetry('/api/acled', 'ERROR');
return Response.json({
error: `Fetch failed: ${toErrorMessage(error)}`,
data: [],
}, {
status: 500,
headers: corsHeaders,
});
}
}