mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
The Cache API spec forbids storing partial responses (HTTP 206). Workbox's cacheableResponse plugin passes them through, but the browser's Cache.put() throws TypeError. PMTiles Range responses are already cached by the browser HTTP cache and CF edge.
1175 lines
48 KiB
TypeScript
1175 lines
48 KiB
TypeScript
import { defineConfig, type Plugin } from 'vite';
|
|
import { VitePWA } from 'vite-plugin-pwa';
|
|
import { resolve, dirname, extname } from 'path';
|
|
import { mkdir, readFile, writeFile } from 'fs/promises';
|
|
import { brotliCompress } from 'zlib';
|
|
import { promisify } from 'util';
|
|
import pkg from './package.json';
|
|
import { VARIANT_META } from './src/config/variant-meta';
|
|
|
|
const isE2E = process.env.VITE_E2E === '1';
|
|
const isDesktopBuild = process.env.VITE_DESKTOP_RUNTIME === '1';
|
|
|
|
const brotliCompressAsync = promisify(brotliCompress);
|
|
const BROTLI_EXTENSIONS = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.json', '.txt', '.xml', '.wasm']);
|
|
|
|
function brotliPrecompressPlugin(): Plugin {
|
|
return {
|
|
name: 'brotli-precompress',
|
|
apply: 'build',
|
|
async writeBundle(outputOptions, bundle) {
|
|
const outDir = outputOptions.dir;
|
|
if (!outDir) return;
|
|
|
|
await Promise.all(Object.keys(bundle).map(async (fileName) => {
|
|
const extension = extname(fileName).toLowerCase();
|
|
if (!BROTLI_EXTENSIONS.has(extension)) return;
|
|
|
|
const sourcePath = resolve(outDir, fileName);
|
|
const compressedPath = `${sourcePath}.br`;
|
|
const sourceBuffer = await readFile(sourcePath);
|
|
if (sourceBuffer.length < 1024) return;
|
|
|
|
const compressedBuffer = await brotliCompressAsync(sourceBuffer);
|
|
await mkdir(dirname(compressedPath), { recursive: true });
|
|
await writeFile(compressedPath, compressedBuffer);
|
|
}));
|
|
},
|
|
};
|
|
}
|
|
|
|
const activeVariant = process.env.VITE_VARIANT || 'full';
|
|
const activeMeta = VARIANT_META[activeVariant] || VARIANT_META.full;
|
|
|
|
function htmlVariantPlugin(): Plugin {
|
|
return {
|
|
name: 'html-variant',
|
|
transformIndexHtml(html) {
|
|
let result = html
|
|
.replace(/<title>.*?<\/title>/, `<title>${activeMeta.title}</title>`)
|
|
.replace(/<meta name="title" content=".*?" \/>/, `<meta name="title" content="${activeMeta.title}" />`)
|
|
.replace(/<meta name="description" content=".*?" \/>/, `<meta name="description" content="${activeMeta.description}" />`)
|
|
.replace(/<meta name="keywords" content=".*?" \/>/, `<meta name="keywords" content="${activeMeta.keywords}" />`)
|
|
.replace(/<link rel="canonical" href=".*?" \/>/, `<link rel="canonical" href="${activeMeta.url}" />`)
|
|
.replace(/<meta name="application-name" content=".*?" \/>/, `<meta name="application-name" content="${activeMeta.siteName}" />`)
|
|
.replace(/<meta property="og:url" content=".*?" \/>/, `<meta property="og:url" content="${activeMeta.url}" />`)
|
|
.replace(/<meta property="og:title" content=".*?" \/>/, `<meta property="og:title" content="${activeMeta.title}" />`)
|
|
.replace(/<meta property="og:description" content=".*?" \/>/, `<meta property="og:description" content="${activeMeta.description}" />`)
|
|
.replace(/<meta property="og:site_name" content=".*?" \/>/, `<meta property="og:site_name" content="${activeMeta.siteName}" />`)
|
|
.replace(/<meta name="subject" content=".*?" \/>/, `<meta name="subject" content="${activeMeta.subject}" />`)
|
|
.replace(/<meta name="classification" content=".*?" \/>/, `<meta name="classification" content="${activeMeta.classification}" />`)
|
|
.replace(/<meta name="twitter:url" content=".*?" \/>/, `<meta name="twitter:url" content="${activeMeta.url}" />`)
|
|
.replace(/<meta name="twitter:title" content=".*?" \/>/, `<meta name="twitter:title" content="${activeMeta.title}" />`)
|
|
.replace(/<meta name="twitter:description" content=".*?" \/>/, `<meta name="twitter:description" content="${activeMeta.description}" />`)
|
|
.replace(/"name": "World Monitor"/, `"name": "${activeMeta.siteName}"`)
|
|
.replace(/"alternateName": "WorldMonitor"/, `"alternateName": "${activeMeta.siteName.replace(' ', '')}"`)
|
|
.replace(/"url": "https:\/\/worldmonitor\.app\/"/, `"url": "${activeMeta.url}"`)
|
|
.replace(/"description": "Real-time global intelligence dashboard with live news, markets, military tracking, infrastructure monitoring, and geopolitical data."/, `"description": "${activeMeta.description}"`)
|
|
.replace(/"featureList": \[[\s\S]*?\]/, `"featureList": ${JSON.stringify(activeMeta.features, null, 8).replace(/\n/g, '\n ')}`);
|
|
|
|
// Theme-color meta — warm cream for happy variant
|
|
if (activeVariant === 'happy') {
|
|
result = result.replace(
|
|
/<meta name="theme-color" content=".*?" \/>/,
|
|
'<meta name="theme-color" content="#FAFAF5" />'
|
|
);
|
|
}
|
|
|
|
// Desktop builds: inject build-time variant into the inline script so data-variant is set
|
|
// before CSS loads. Web builds always use 'full' — runtime hostname detection handles variants.
|
|
if (activeVariant !== 'full') {
|
|
result = result.replace(
|
|
/if\(v\)document\.documentElement\.dataset\.variant=v;/,
|
|
`v='${activeVariant}';document.documentElement.dataset.variant=v;`
|
|
);
|
|
}
|
|
|
|
// Desktop CSP: inject localhost wildcard for dynamic sidecar port.
|
|
// Web builds intentionally exclude localhost to avoid exposing attack surface.
|
|
if (isDesktopBuild) {
|
|
result = result
|
|
.replace(
|
|
/connect-src 'self' https: http:\/\/localhost:5173/,
|
|
"connect-src 'self' https: http://localhost:5173 http://127.0.0.1:*"
|
|
)
|
|
.replace(
|
|
/frame-src 'self'/,
|
|
"frame-src 'self' http://127.0.0.1:*"
|
|
);
|
|
}
|
|
|
|
// Desktop builds: replace favicon paths with variant-specific subdirectory.
|
|
// Web builds use 'full' favicons in HTML; runtime JS swaps them per hostname.
|
|
if (activeVariant !== 'full') {
|
|
result = result
|
|
.replace(/\/favico\/favicon/g, `/favico/${activeVariant}/favicon`)
|
|
.replace(/\/favico\/apple-touch-icon/g, `/favico/${activeVariant}/apple-touch-icon`)
|
|
.replace(/\/favico\/android-chrome/g, `/favico/${activeVariant}/android-chrome`)
|
|
.replace(/\/favico\/og-image/g, `/favico/${activeVariant}/og-image`);
|
|
}
|
|
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
|
|
function polymarketPlugin(): Plugin {
|
|
const GAMMA_BASE = 'https://gamma-api.polymarket.com';
|
|
const ALLOWED_ORDER = ['volume', 'liquidity', 'startDate', 'endDate', 'spread'];
|
|
|
|
return {
|
|
name: 'polymarket-dev',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url?.startsWith('/api/polymarket')) return next();
|
|
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const endpoint = url.searchParams.get('endpoint') || 'markets';
|
|
const closed = ['true', 'false'].includes(url.searchParams.get('closed') ?? '') ? url.searchParams.get('closed') : 'false';
|
|
const order = ALLOWED_ORDER.includes(url.searchParams.get('order') ?? '') ? url.searchParams.get('order') : 'volume';
|
|
const ascending = ['true', 'false'].includes(url.searchParams.get('ascending') ?? '') ? url.searchParams.get('ascending') : 'false';
|
|
const rawLimit = parseInt(url.searchParams.get('limit') ?? '', 10);
|
|
const limit = isNaN(rawLimit) ? 50 : Math.max(1, Math.min(100, rawLimit));
|
|
|
|
const params = new URLSearchParams({ closed: closed!, order: order!, ascending: ascending!, limit: String(limit) });
|
|
if (endpoint === 'events') {
|
|
const tag = (url.searchParams.get('tag') ?? '').replace(/[^a-z0-9-]/gi, '').slice(0, 100);
|
|
if (tag) params.set('tag_slug', tag);
|
|
}
|
|
|
|
const gammaUrl = `${GAMMA_BASE}/${endpoint === 'events' ? 'events' : 'markets'}?${params}`;
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 8000);
|
|
const resp = await fetch(gammaUrl, { headers: { Accept: 'application/json' }, signal: controller.signal });
|
|
clearTimeout(timer);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.text();
|
|
res.setHeader('Cache-Control', 'public, max-age=120');
|
|
res.setHeader('X-Polymarket-Source', 'gamma');
|
|
res.end(data);
|
|
} catch {
|
|
// Expected: Cloudflare JA3 blocks server-side TLS — return empty array
|
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
res.end('[]');
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Vite dev server plugin for sebuf API routes.
|
|
*
|
|
* Intercepts requests matching /api/{domain}/v1/* and routes them through
|
|
* the same handler pipeline as the Vercel catch-all gateway. Other /api/*
|
|
* paths fall through to existing proxy rules.
|
|
*/
|
|
function sebufApiPlugin(): Plugin {
|
|
// Cache router across requests (H-13 fix). Invalidated by Vite's module graph on HMR.
|
|
let cachedRouter: Awaited<ReturnType<typeof buildRouter>> | null = null;
|
|
let cachedCorsMod: any = null;
|
|
|
|
async function buildRouter() {
|
|
const [
|
|
routerMod, corsMod, errorMod,
|
|
seismologyServerMod, seismologyHandlerMod,
|
|
wildfireServerMod, wildfireHandlerMod,
|
|
climateServerMod, climateHandlerMod,
|
|
predictionServerMod, predictionHandlerMod,
|
|
displacementServerMod, displacementHandlerMod,
|
|
aviationServerMod, aviationHandlerMod,
|
|
researchServerMod, researchHandlerMod,
|
|
unrestServerMod, unrestHandlerMod,
|
|
conflictServerMod, conflictHandlerMod,
|
|
maritimeServerMod, maritimeHandlerMod,
|
|
cyberServerMod, cyberHandlerMod,
|
|
economicServerMod, economicHandlerMod,
|
|
infrastructureServerMod, infrastructureHandlerMod,
|
|
marketServerMod, marketHandlerMod,
|
|
newsServerMod, newsHandlerMod,
|
|
intelligenceServerMod, intelligenceHandlerMod,
|
|
militaryServerMod, militaryHandlerMod,
|
|
positiveEventsServerMod, positiveEventsHandlerMod,
|
|
givingServerMod, givingHandlerMod,
|
|
tradeServerMod, tradeHandlerMod,
|
|
supplyChainServerMod, supplyChainHandlerMod,
|
|
naturalServerMod, naturalHandlerMod,
|
|
] = await Promise.all([
|
|
import('./server/router'),
|
|
import('./server/cors'),
|
|
import('./server/error-mapper'),
|
|
import('./src/generated/server/worldmonitor/seismology/v1/service_server'),
|
|
import('./server/worldmonitor/seismology/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/wildfire/v1/service_server'),
|
|
import('./server/worldmonitor/wildfire/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/climate/v1/service_server'),
|
|
import('./server/worldmonitor/climate/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/prediction/v1/service_server'),
|
|
import('./server/worldmonitor/prediction/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/displacement/v1/service_server'),
|
|
import('./server/worldmonitor/displacement/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/aviation/v1/service_server'),
|
|
import('./server/worldmonitor/aviation/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/research/v1/service_server'),
|
|
import('./server/worldmonitor/research/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/unrest/v1/service_server'),
|
|
import('./server/worldmonitor/unrest/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/conflict/v1/service_server'),
|
|
import('./server/worldmonitor/conflict/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/maritime/v1/service_server'),
|
|
import('./server/worldmonitor/maritime/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/cyber/v1/service_server'),
|
|
import('./server/worldmonitor/cyber/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/economic/v1/service_server'),
|
|
import('./server/worldmonitor/economic/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/infrastructure/v1/service_server'),
|
|
import('./server/worldmonitor/infrastructure/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/market/v1/service_server'),
|
|
import('./server/worldmonitor/market/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/news/v1/service_server'),
|
|
import('./server/worldmonitor/news/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/intelligence/v1/service_server'),
|
|
import('./server/worldmonitor/intelligence/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/military/v1/service_server'),
|
|
import('./server/worldmonitor/military/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/positive_events/v1/service_server'),
|
|
import('./server/worldmonitor/positive-events/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/giving/v1/service_server'),
|
|
import('./server/worldmonitor/giving/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/trade/v1/service_server'),
|
|
import('./server/worldmonitor/trade/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/supply_chain/v1/service_server'),
|
|
import('./server/worldmonitor/supply-chain/v1/handler'),
|
|
import('./src/generated/server/worldmonitor/natural/v1/service_server'),
|
|
import('./server/worldmonitor/natural/v1/handler'),
|
|
]);
|
|
|
|
const serverOptions = { onError: errorMod.mapErrorToResponse };
|
|
const allRoutes = [
|
|
...seismologyServerMod.createSeismologyServiceRoutes(seismologyHandlerMod.seismologyHandler, serverOptions),
|
|
...wildfireServerMod.createWildfireServiceRoutes(wildfireHandlerMod.wildfireHandler, serverOptions),
|
|
...climateServerMod.createClimateServiceRoutes(climateHandlerMod.climateHandler, serverOptions),
|
|
...predictionServerMod.createPredictionServiceRoutes(predictionHandlerMod.predictionHandler, serverOptions),
|
|
...displacementServerMod.createDisplacementServiceRoutes(displacementHandlerMod.displacementHandler, serverOptions),
|
|
...aviationServerMod.createAviationServiceRoutes(aviationHandlerMod.aviationHandler, serverOptions),
|
|
...researchServerMod.createResearchServiceRoutes(researchHandlerMod.researchHandler, serverOptions),
|
|
...unrestServerMod.createUnrestServiceRoutes(unrestHandlerMod.unrestHandler, serverOptions),
|
|
...conflictServerMod.createConflictServiceRoutes(conflictHandlerMod.conflictHandler, serverOptions),
|
|
...maritimeServerMod.createMaritimeServiceRoutes(maritimeHandlerMod.maritimeHandler, serverOptions),
|
|
...cyberServerMod.createCyberServiceRoutes(cyberHandlerMod.cyberHandler, serverOptions),
|
|
...economicServerMod.createEconomicServiceRoutes(economicHandlerMod.economicHandler, serverOptions),
|
|
...infrastructureServerMod.createInfrastructureServiceRoutes(infrastructureHandlerMod.infrastructureHandler, serverOptions),
|
|
...marketServerMod.createMarketServiceRoutes(marketHandlerMod.marketHandler, serverOptions),
|
|
...newsServerMod.createNewsServiceRoutes(newsHandlerMod.newsHandler, serverOptions),
|
|
...intelligenceServerMod.createIntelligenceServiceRoutes(intelligenceHandlerMod.intelligenceHandler, serverOptions),
|
|
...militaryServerMod.createMilitaryServiceRoutes(militaryHandlerMod.militaryHandler, serverOptions),
|
|
...positiveEventsServerMod.createPositiveEventsServiceRoutes(positiveEventsHandlerMod.positiveEventsHandler, serverOptions),
|
|
...givingServerMod.createGivingServiceRoutes(givingHandlerMod.givingHandler, serverOptions),
|
|
...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions),
|
|
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
|
|
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
|
|
];
|
|
cachedCorsMod = corsMod;
|
|
return routerMod.createRouter(allRoutes);
|
|
}
|
|
|
|
return {
|
|
name: 'sebuf-api',
|
|
configureServer(server) {
|
|
// Invalidate cached router on HMR updates to server/ files
|
|
server.watcher.on('change', (file) => {
|
|
if (file.includes('/server/') || file.includes('/src/generated/server/')) {
|
|
cachedRouter = null;
|
|
}
|
|
});
|
|
|
|
server.middlewares.use(async (req, res, next) => {
|
|
// Only intercept sebuf routes: /api/{domain}/v1/* (domain may contain hyphens)
|
|
if (!req.url || !/^\/api\/[a-z-]+\/v1\//.test(req.url)) {
|
|
return next();
|
|
}
|
|
|
|
try {
|
|
// Build router once, reuse across requests (H-13 fix)
|
|
if (!cachedRouter) {
|
|
cachedRouter = await buildRouter();
|
|
}
|
|
const router = cachedRouter;
|
|
const corsMod = cachedCorsMod;
|
|
|
|
// Convert Connect IncomingMessage to Web Standard Request
|
|
const port = server.config.server.port || 3000;
|
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
|
|
// Read body for POST requests
|
|
let body: string | undefined;
|
|
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
}
|
|
body = Buffer.concat(chunks).toString();
|
|
}
|
|
|
|
// Extract headers from IncomingMessage
|
|
const headers: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
if (typeof value === 'string') {
|
|
headers[key] = value;
|
|
} else if (Array.isArray(value)) {
|
|
headers[key] = value.join(', ');
|
|
}
|
|
}
|
|
|
|
const webRequest = new Request(url.toString(), {
|
|
method: req.method,
|
|
headers,
|
|
body: body || undefined,
|
|
});
|
|
|
|
const corsHeaders = corsMod.getCorsHeaders(webRequest);
|
|
|
|
// OPTIONS preflight
|
|
if (req.method === 'OPTIONS') {
|
|
res.statusCode = 204;
|
|
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Origin check
|
|
if (corsMod.isDisallowedOrigin(webRequest)) {
|
|
res.statusCode = 403;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
res.end(JSON.stringify({ error: 'Origin not allowed' }));
|
|
return;
|
|
}
|
|
|
|
// Route matching
|
|
const matchedHandler = router.match(webRequest);
|
|
if (!matchedHandler) {
|
|
const allowed = router.allowedMethods(new URL(webRequest.url).pathname);
|
|
if (allowed.length > 0) {
|
|
res.statusCode = 405;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Allow', allowed.join(', '));
|
|
} else {
|
|
res.statusCode = 404;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
}
|
|
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
res.end(JSON.stringify({ error: res.statusCode === 405 ? 'Method not allowed' : 'Not found' }));
|
|
return;
|
|
}
|
|
|
|
// Execute handler
|
|
const response = await matchedHandler(webRequest);
|
|
|
|
// Write response
|
|
res.statusCode = response.status;
|
|
response.headers.forEach((value, key) => {
|
|
res.setHeader(key, value);
|
|
});
|
|
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
res.end(await response.text());
|
|
} catch (err) {
|
|
console.error('[sebuf-api] Error:', err);
|
|
res.statusCode = 500;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
// RSS proxy allowlist — duplicated from api/rss-proxy.js for dev mode.
|
|
// Keep in sync when adding new domains.
|
|
const RSS_PROXY_ALLOWED_DOMAINS = new Set([
|
|
'feeds.bbci.co.uk', 'www.theguardian.com', 'feeds.npr.org', 'news.google.com',
|
|
'www.aljazeera.com', 'rss.cnn.com', 'hnrss.org', 'feeds.arstechnica.com',
|
|
'www.theverge.com', 'www.cnbc.com', 'feeds.marketwatch.com', 'www.defenseone.com',
|
|
'breakingdefense.com', 'www.bellingcat.com', 'techcrunch.com', 'huggingface.co',
|
|
'www.technologyreview.com', 'rss.arxiv.org', 'export.arxiv.org',
|
|
'www.federalreserve.gov', 'www.sec.gov', 'www.whitehouse.gov', 'www.state.gov',
|
|
'www.defense.gov', 'home.treasury.gov', 'www.justice.gov', 'tools.cdc.gov',
|
|
'www.fema.gov', 'www.dhs.gov', 'www.thedrive.com', 'krebsonsecurity.com',
|
|
'finance.yahoo.com', 'thediplomat.com', 'venturebeat.com', 'foreignpolicy.com',
|
|
'www.ft.com', 'openai.com', 'www.reutersagency.com', 'feeds.reuters.com',
|
|
'asia.nikkei.com', 'www.cfr.org', 'www.csis.org', 'www.politico.com',
|
|
'www.brookings.edu', 'layoffs.fyi', 'www.defensenews.com', 'www.militarytimes.com',
|
|
'taskandpurpose.com', 'news.usni.org', 'www.oryxspioenkop.com', 'www.gov.uk',
|
|
'www.foreignaffairs.com', 'www.atlanticcouncil.org',
|
|
// Tech variant
|
|
'www.zdnet.com', 'www.techmeme.com', 'www.darkreading.com', 'www.schneier.com',
|
|
'rss.politico.com', 'www.anandtech.com', 'www.tomshardware.com', 'www.semianalysis.com',
|
|
'feed.infoq.com', 'thenewstack.io', 'devops.com', 'dev.to', 'lobste.rs', 'changelog.com',
|
|
'seekingalpha.com', 'news.crunchbase.com', 'www.saastr.com', 'feeds.feedburner.com',
|
|
'www.producthunt.com', 'www.axios.com', 'api.axios.com', 'github.blog', 'githubnext.com',
|
|
'mshibanami.github.io', 'www.engadget.com', 'news.mit.edu', 'dev.events',
|
|
'www.ycombinator.com', 'a16z.com', 'review.firstround.com', 'www.sequoiacap.com',
|
|
'www.nfx.com', 'www.aaronsw.com', 'bothsidesofthetable.com', 'www.lennysnewsletter.com',
|
|
'stratechery.com', 'www.eu-startups.com', 'tech.eu', 'sifted.eu', 'www.techinasia.com',
|
|
'kr-asia.com', 'techcabal.com', 'disrupt-africa.com', 'lavca.org', 'contxto.com',
|
|
'inc42.com', 'yourstory.com', 'pitchbook.com', 'www.cbinsights.com', 'www.techstars.com',
|
|
// Regional & international
|
|
'english.alarabiya.net', 'www.arabnews.com', 'www.timesofisrael.com', 'www.haaretz.com',
|
|
'www.scmp.com', 'kyivindependent.com', 'www.themoscowtimes.com', 'feeds.24.com',
|
|
'feeds.capi24.com', 'www.france24.com', 'www.euronews.com', 'www.lemonde.fr',
|
|
'rss.dw.com', 'www.africanews.com', 'www.lasillavacia.com', 'www.channelnewsasia.com',
|
|
'www.thehindu.com', 'news.un.org', 'www.iaea.org', 'www.who.int', 'www.cisa.gov',
|
|
'www.crisisgroup.org',
|
|
// Think tanks
|
|
'rusi.org', 'warontherocks.com', 'www.aei.org', 'responsiblestatecraft.org',
|
|
'www.fpri.org', 'jamestown.org', 'www.chathamhouse.org', 'ecfr.eu', 'www.gmfus.org',
|
|
'www.wilsoncenter.org', 'www.lowyinstitute.org', 'www.mei.edu', 'www.stimson.org',
|
|
'www.cnas.org', 'carnegieendowment.org', 'www.rand.org', 'fas.org',
|
|
'www.armscontrol.org', 'www.nti.org', 'thebulletin.org', 'www.iss.europa.eu',
|
|
// Economic & Food Security
|
|
'www.fao.org', 'worldbank.org', 'www.imf.org',
|
|
// Regional locale feeds
|
|
'www.hurriyet.com.tr', 'tvn24.pl', 'www.polsatnews.pl', 'www.rp.pl', 'meduza.io',
|
|
'novayagazeta.eu', 'www.bangkokpost.com', 'vnexpress.net', 'www.abc.net.au',
|
|
'news.ycombinator.com',
|
|
// Finance variant
|
|
'www.coindesk.com', 'cointelegraph.com',
|
|
// Happy variant — positive news sources
|
|
'www.goodnewsnetwork.org', 'www.positive.news', 'reasonstobecheerful.world',
|
|
'www.optimistdaily.com', 'www.sunnyskyz.com', 'www.huffpost.com',
|
|
'www.sciencedaily.com', 'feeds.nature.com', 'www.livescience.com', 'www.newscientist.com',
|
|
]);
|
|
|
|
function rssProxyPlugin(): Plugin {
|
|
return {
|
|
name: 'rss-proxy',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url?.startsWith('/api/rss-proxy')) {
|
|
return next();
|
|
}
|
|
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const feedUrl = url.searchParams.get('url');
|
|
if (!feedUrl) {
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: 'Missing url parameter' }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(feedUrl);
|
|
if (!RSS_PROXY_ALLOWED_DOMAINS.has(parsed.hostname)) {
|
|
res.statusCode = 403;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: `Domain not allowed: ${parsed.hostname}` }));
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = feedUrl.includes('news.google.com') ? 20000 : 12000;
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
|
|
const response = await fetch(feedUrl, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
|
|
},
|
|
redirect: 'follow',
|
|
});
|
|
clearTimeout(timer);
|
|
|
|
const data = await response.text();
|
|
res.statusCode = response.status;
|
|
res.setHeader('Content-Type', 'application/xml');
|
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.end(data);
|
|
} catch (error: any) {
|
|
console.error('[rss-proxy]', feedUrl, error.message);
|
|
res.statusCode = error.name === 'AbortError' ? 504 : 502;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: error.name === 'AbortError' ? 'Feed timeout' : 'Failed to fetch feed' }));
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function youtubeLivePlugin(): Plugin {
|
|
return {
|
|
name: 'youtube-live',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url?.startsWith('/api/youtube/live')) {
|
|
return next();
|
|
}
|
|
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const channel = url.searchParams.get('channel');
|
|
|
|
if (!channel) {
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: 'Missing channel parameter' }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const channelHandle = channel.startsWith('@') ? channel : `@${channel}`;
|
|
const liveUrl = `https://www.youtube.com/${channelHandle}/live`;
|
|
|
|
const ytRes = await fetch(liveUrl, {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
},
|
|
redirect: 'follow',
|
|
});
|
|
|
|
if (!ytRes.ok) {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
res.end(JSON.stringify({ videoId: null, channel }));
|
|
return;
|
|
}
|
|
|
|
const html = await ytRes.text();
|
|
|
|
// Scope both fields to the same videoDetails block so we don't
|
|
// combine a videoId from one object with isLive from another.
|
|
let videoId: string | null = null;
|
|
const detailsIdx = html.indexOf('"videoDetails"');
|
|
if (detailsIdx !== -1) {
|
|
const block = html.substring(detailsIdx, detailsIdx + 5000);
|
|
const vidMatch = block.match(/"videoId":"([a-zA-Z0-9_-]{11})"/);
|
|
const liveMatch = block.match(/"isLive"\s*:\s*true/);
|
|
if (vidMatch && liveMatch) {
|
|
videoId = vidMatch[1];
|
|
}
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
res.end(JSON.stringify({ videoId, isLive: videoId !== null, channel }));
|
|
} catch (error) {
|
|
console.error(`[YouTube Live] Error:`, error);
|
|
res.statusCode = 500;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: 'Failed to fetch', videoId: null }));
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
define: {
|
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
|
},
|
|
plugins: [
|
|
htmlVariantPlugin(),
|
|
polymarketPlugin(),
|
|
rssProxyPlugin(),
|
|
youtubeLivePlugin(),
|
|
sebufApiPlugin(),
|
|
brotliPrecompressPlugin(),
|
|
VitePWA({
|
|
registerType: 'autoUpdate',
|
|
injectRegister: false,
|
|
|
|
includeAssets: [
|
|
'favico/favicon.ico',
|
|
'favico/apple-touch-icon.png',
|
|
'favico/favicon-32x32.png',
|
|
],
|
|
|
|
manifest: {
|
|
name: `${activeMeta.siteName} - ${activeMeta.subject}`,
|
|
short_name: activeMeta.shortName,
|
|
description: activeMeta.description,
|
|
start_url: '/',
|
|
scope: '/',
|
|
display: 'standalone',
|
|
orientation: 'any',
|
|
theme_color: '#0a0f0a',
|
|
background_color: '#0a0f0a',
|
|
categories: activeMeta.categories,
|
|
icons: [
|
|
{ src: '/favico/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
|
|
{ src: '/favico/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
|
|
{ src: '/favico/android-chrome-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
],
|
|
},
|
|
|
|
workbox: {
|
|
globPatterns: ['**/*.{js,css,ico,png,svg,woff2}'],
|
|
globIgnores: ['**/ml*.js', '**/onnx*.wasm', '**/locale-*.js'],
|
|
// globe.gl + three.js grows main bundle past the 2 MiB default limit
|
|
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
|
navigateFallback: null,
|
|
skipWaiting: true,
|
|
clientsClaim: true,
|
|
cleanupOutdatedCaches: true,
|
|
|
|
runtimeCaching: [
|
|
{
|
|
urlPattern: ({ request }: { request: Request }) => request.mode === 'navigate',
|
|
handler: 'NetworkOnly',
|
|
},
|
|
{
|
|
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
|
|
sameOrigin && /^\/api\//.test(url.pathname),
|
|
handler: 'NetworkOnly',
|
|
method: 'GET',
|
|
},
|
|
{
|
|
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
|
|
sameOrigin && /^\/api\//.test(url.pathname),
|
|
handler: 'NetworkOnly',
|
|
method: 'POST',
|
|
},
|
|
{
|
|
urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) =>
|
|
sameOrigin && /^\/rss\//.test(url.pathname),
|
|
handler: 'NetworkOnly',
|
|
method: 'GET',
|
|
},
|
|
{
|
|
urlPattern: ({ url }: { url: URL }) =>
|
|
url.pathname.endsWith('.pmtiles') ||
|
|
url.hostname.endsWith('.r2.dev') ||
|
|
url.hostname === 'build.protomaps.com',
|
|
handler: 'NetworkFirst',
|
|
options: {
|
|
cacheName: 'pmtiles-ranges',
|
|
expiration: { maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
urlPattern: /^https:\/\/protomaps\.github\.io\//,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'protomaps-assets',
|
|
expiration: { maxEntries: 100, maxAgeSeconds: 365 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
urlPattern: /^https:\/\/fonts\.googleapis\.com\//,
|
|
handler: 'StaleWhileRevalidate',
|
|
options: {
|
|
cacheName: 'google-fonts-css',
|
|
expiration: { maxEntries: 10, maxAgeSeconds: 365 * 24 * 60 * 60 },
|
|
},
|
|
},
|
|
{
|
|
urlPattern: /^https:\/\/fonts\.gstatic\.com\//,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'google-fonts-woff',
|
|
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
urlPattern: /\/assets\/locale-.*\.js$/i,
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'locale-files',
|
|
expiration: { maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
|
cacheableResponse: { statuses: [0, 200] },
|
|
},
|
|
},
|
|
{
|
|
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
|
|
handler: 'StaleWhileRevalidate',
|
|
options: {
|
|
cacheName: 'images',
|
|
expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
|
|
devOptions: {
|
|
enabled: false,
|
|
},
|
|
}),
|
|
],
|
|
resolve: {
|
|
alias: {
|
|
'@': resolve(__dirname, 'src'),
|
|
child_process: resolve(__dirname, 'src/shims/child-process.ts'),
|
|
'node:child_process': resolve(__dirname, 'src/shims/child-process.ts'),
|
|
'@loaders.gl/worker-utils/dist/lib/process-utils/child-process-proxy.js': resolve(
|
|
__dirname,
|
|
'src/shims/child-process-proxy.ts'
|
|
),
|
|
},
|
|
},
|
|
build: {
|
|
// Geospatial bundles (maplibre/deck) are expected to be large even when split.
|
|
// Raise warning threshold to reduce noisy false alarms in CI.
|
|
chunkSizeWarningLimit: 1200,
|
|
rollupOptions: {
|
|
onwarn(warning, warn) {
|
|
// onnxruntime-web ships a minified browser bundle that intentionally uses eval.
|
|
// Keep build logs focused by filtering this known third-party warning only.
|
|
if (
|
|
warning.code === 'EVAL'
|
|
&& typeof warning.id === 'string'
|
|
&& warning.id.includes('/onnxruntime-web/dist/ort-web.min.js')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
warn(warning);
|
|
},
|
|
input: {
|
|
main: resolve(__dirname, 'index.html'),
|
|
settings: resolve(__dirname, 'settings.html'),
|
|
liveChannels: resolve(__dirname, 'live-channels.html'),
|
|
},
|
|
output: {
|
|
manualChunks(id) {
|
|
if (id.includes('node_modules')) {
|
|
if (id.includes('/@xenova/transformers/')) {
|
|
return 'transformers';
|
|
}
|
|
if (id.includes('/onnxruntime-web/')) {
|
|
return 'onnxruntime';
|
|
}
|
|
if (id.includes('/maplibre-gl/') || id.includes('/pmtiles/') || id.includes('/@protomaps/basemaps/')) {
|
|
return 'maplibre';
|
|
}
|
|
if (
|
|
id.includes('/@deck.gl/')
|
|
|| id.includes('/@luma.gl/')
|
|
|| id.includes('/@loaders.gl/')
|
|
|| id.includes('/@math.gl/')
|
|
|| id.includes('/h3-js/')
|
|
) {
|
|
return 'deck-stack';
|
|
}
|
|
if (id.includes('/d3/')) {
|
|
return 'd3';
|
|
}
|
|
if (id.includes('/topojson-client/')) {
|
|
return 'topojson';
|
|
}
|
|
if (id.includes('/i18next')) {
|
|
return 'i18n';
|
|
}
|
|
if (id.includes('/@sentry/')) {
|
|
return 'sentry';
|
|
}
|
|
}
|
|
if (id.includes('/src/components/') && id.endsWith('Panel.ts')) {
|
|
return 'panels';
|
|
}
|
|
// Give lazy-loaded locale chunks a recognizable prefix so the
|
|
// service worker can exclude them from precache (en.json is
|
|
// statically imported into the main bundle).
|
|
const localeMatch = id.match(/\/locales\/(\w+)\.json$/);
|
|
if (localeMatch && localeMatch[1] !== 'en') {
|
|
return `locale-${localeMatch[1]}`;
|
|
}
|
|
return undefined;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
server: {
|
|
port: 3000,
|
|
open: !isE2E,
|
|
hmr: isE2E ? false : undefined,
|
|
watch: {
|
|
ignored: [
|
|
'**/test-results/**',
|
|
'**/playwright-report/**',
|
|
'**/.playwright-mcp/**',
|
|
],
|
|
},
|
|
proxy: {
|
|
// Yahoo Finance API
|
|
'/api/yahoo': {
|
|
target: 'https://query1.finance.yahoo.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/api\/yahoo/, ''),
|
|
},
|
|
// Polymarket handled by polymarketPlugin() — no prod proxy needed
|
|
// USGS Earthquake API
|
|
'/api/earthquake': {
|
|
target: 'https://earthquake.usgs.gov',
|
|
changeOrigin: true,
|
|
timeout: 30000,
|
|
rewrite: (path) => path.replace(/^\/api\/earthquake/, ''),
|
|
configure: (proxy) => {
|
|
proxy.on('error', (err) => {
|
|
console.log('Earthquake proxy error:', err.message);
|
|
});
|
|
},
|
|
},
|
|
// PizzINT - Pentagon Pizza Index
|
|
'/api/pizzint': {
|
|
target: 'https://www.pizzint.watch',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/api\/pizzint/, '/api'),
|
|
configure: (proxy) => {
|
|
proxy.on('error', (err) => {
|
|
console.log('PizzINT proxy error:', err.message);
|
|
});
|
|
},
|
|
},
|
|
// FRED Economic Data - handled by Vercel serverless function in prod
|
|
// In dev, we proxy to the API directly with the key from .env
|
|
'/api/fred-data': {
|
|
target: 'https://api.stlouisfed.org',
|
|
changeOrigin: true,
|
|
rewrite: (path) => {
|
|
const url = new URL(path, 'http://localhost');
|
|
const seriesId = url.searchParams.get('series_id');
|
|
const start = url.searchParams.get('observation_start');
|
|
const end = url.searchParams.get('observation_end');
|
|
const apiKey = process.env.FRED_API_KEY || '';
|
|
return `/fred/series/observations?series_id=${seriesId}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=10${start ? `&observation_start=${start}` : ''}${end ? `&observation_end=${end}` : ''}`;
|
|
},
|
|
},
|
|
// RSS Feeds - BBC
|
|
'/rss/bbc': {
|
|
target: 'https://feeds.bbci.co.uk',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/bbc/, ''),
|
|
},
|
|
// RSS Feeds - Guardian
|
|
'/rss/guardian': {
|
|
target: 'https://www.theguardian.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/guardian/, ''),
|
|
},
|
|
// RSS Feeds - NPR
|
|
'/rss/npr': {
|
|
target: 'https://feeds.npr.org',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/npr/, ''),
|
|
},
|
|
// RSS Feeds - Al Jazeera
|
|
'/rss/aljazeera': {
|
|
target: 'https://www.aljazeera.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/aljazeera/, ''),
|
|
},
|
|
// RSS Feeds - CNN
|
|
'/rss/cnn': {
|
|
target: 'http://rss.cnn.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/cnn/, ''),
|
|
},
|
|
// RSS Feeds - Hacker News
|
|
'/rss/hn': {
|
|
target: 'https://hnrss.org',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/hn/, ''),
|
|
},
|
|
// RSS Feeds - Ars Technica
|
|
'/rss/arstechnica': {
|
|
target: 'https://feeds.arstechnica.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/arstechnica/, ''),
|
|
},
|
|
// RSS Feeds - The Verge
|
|
'/rss/verge': {
|
|
target: 'https://www.theverge.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/verge/, ''),
|
|
},
|
|
// RSS Feeds - CNBC
|
|
'/rss/cnbc': {
|
|
target: 'https://www.cnbc.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/cnbc/, ''),
|
|
},
|
|
// RSS Feeds - MarketWatch
|
|
'/rss/marketwatch': {
|
|
target: 'https://feeds.marketwatch.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/marketwatch/, ''),
|
|
},
|
|
// RSS Feeds - Defense/Intel sources
|
|
'/rss/defenseone': {
|
|
target: 'https://www.defenseone.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/defenseone/, ''),
|
|
},
|
|
'/rss/warontherocks': {
|
|
target: 'https://warontherocks.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/warontherocks/, ''),
|
|
},
|
|
'/rss/breakingdefense': {
|
|
target: 'https://breakingdefense.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/breakingdefense/, ''),
|
|
},
|
|
'/rss/bellingcat': {
|
|
target: 'https://www.bellingcat.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/bellingcat/, ''),
|
|
},
|
|
// RSS Feeds - TechCrunch (layoffs)
|
|
'/rss/techcrunch': {
|
|
target: 'https://techcrunch.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/techcrunch/, ''),
|
|
},
|
|
// Google News RSS
|
|
'/rss/googlenews': {
|
|
target: 'https://news.google.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/googlenews/, ''),
|
|
},
|
|
// AI Company Blogs
|
|
'/rss/openai': {
|
|
target: 'https://openai.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/openai/, ''),
|
|
},
|
|
'/rss/anthropic': {
|
|
target: 'https://www.anthropic.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/anthropic/, ''),
|
|
},
|
|
'/rss/googleai': {
|
|
target: 'https://blog.google',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/googleai/, ''),
|
|
},
|
|
'/rss/deepmind': {
|
|
target: 'https://deepmind.google',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/deepmind/, ''),
|
|
},
|
|
'/rss/huggingface': {
|
|
target: 'https://huggingface.co',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/huggingface/, ''),
|
|
},
|
|
'/rss/techreview': {
|
|
target: 'https://www.technologyreview.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/techreview/, ''),
|
|
},
|
|
'/rss/arxiv': {
|
|
target: 'https://rss.arxiv.org',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/arxiv/, ''),
|
|
},
|
|
// Government
|
|
'/rss/whitehouse': {
|
|
target: 'https://www.whitehouse.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/whitehouse/, ''),
|
|
},
|
|
'/rss/statedept': {
|
|
target: 'https://www.state.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/statedept/, ''),
|
|
},
|
|
'/rss/state': {
|
|
target: 'https://www.state.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/state/, ''),
|
|
},
|
|
'/rss/defense': {
|
|
target: 'https://www.defense.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/defense/, ''),
|
|
},
|
|
'/rss/justice': {
|
|
target: 'https://www.justice.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/justice/, ''),
|
|
},
|
|
'/rss/cdc': {
|
|
target: 'https://tools.cdc.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/cdc/, ''),
|
|
},
|
|
'/rss/fema': {
|
|
target: 'https://www.fema.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/fema/, ''),
|
|
},
|
|
'/rss/dhs': {
|
|
target: 'https://www.dhs.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/dhs/, ''),
|
|
},
|
|
'/rss/fedreserve': {
|
|
target: 'https://www.federalreserve.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/fedreserve/, ''),
|
|
},
|
|
'/rss/sec': {
|
|
target: 'https://www.sec.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/sec/, ''),
|
|
},
|
|
'/rss/treasury': {
|
|
target: 'https://home.treasury.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/treasury/, ''),
|
|
},
|
|
'/rss/cisa': {
|
|
target: 'https://www.cisa.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/cisa/, ''),
|
|
},
|
|
// Think Tanks
|
|
'/rss/brookings': {
|
|
target: 'https://www.brookings.edu',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/brookings/, ''),
|
|
},
|
|
'/rss/cfr': {
|
|
target: 'https://www.cfr.org',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/cfr/, ''),
|
|
},
|
|
'/rss/csis': {
|
|
target: 'https://www.csis.org',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/csis/, ''),
|
|
},
|
|
// Defense
|
|
'/rss/warzone': {
|
|
target: 'https://www.thedrive.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/warzone/, ''),
|
|
},
|
|
'/rss/defensegov': {
|
|
target: 'https://www.defense.gov',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/defensegov/, ''),
|
|
},
|
|
// Security
|
|
'/rss/krebs': {
|
|
target: 'https://krebsonsecurity.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/krebs/, ''),
|
|
},
|
|
// Finance
|
|
'/rss/yahoonews': {
|
|
target: 'https://finance.yahoo.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/yahoonews/, ''),
|
|
},
|
|
// Diplomat
|
|
'/rss/diplomat': {
|
|
target: 'https://thediplomat.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/diplomat/, ''),
|
|
},
|
|
// VentureBeat
|
|
'/rss/venturebeat': {
|
|
target: 'https://venturebeat.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/venturebeat/, ''),
|
|
},
|
|
// Foreign Policy
|
|
'/rss/foreignpolicy': {
|
|
target: 'https://foreignpolicy.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/foreignpolicy/, ''),
|
|
},
|
|
// Financial Times
|
|
'/rss/ft': {
|
|
target: 'https://www.ft.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/ft/, ''),
|
|
},
|
|
// Reuters
|
|
'/rss/reuters': {
|
|
target: 'https://www.reutersagency.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/rss\/reuters/, ''),
|
|
},
|
|
// Cloudflare Radar - Internet outages
|
|
'/api/cloudflare-radar': {
|
|
target: 'https://api.cloudflare.com',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/api\/cloudflare-radar/, ''),
|
|
},
|
|
// NGA Maritime Safety Information - Navigation Warnings
|
|
'/api/nga-msi': {
|
|
target: 'https://msi.nga.mil',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/api\/nga-msi/, ''),
|
|
},
|
|
// GDELT GEO 2.0 API - Global event data
|
|
'/api/gdelt': {
|
|
target: 'https://api.gdeltproject.org',
|
|
changeOrigin: true,
|
|
rewrite: (path) => path.replace(/^\/api\/gdelt/, ''),
|
|
},
|
|
// AISStream WebSocket proxy for live vessel tracking
|
|
'/ws/aisstream': {
|
|
target: 'wss://stream.aisstream.io',
|
|
changeOrigin: true,
|
|
ws: true,
|
|
rewrite: (path) => path.replace(/^\/ws\/aisstream/, ''),
|
|
},
|
|
// FAA NASSTATUS - Airport delays and closures
|
|
'/api/faa': {
|
|
target: 'https://nasstatus.faa.gov',
|
|
changeOrigin: true,
|
|
secure: true,
|
|
rewrite: (path) => path.replace(/^\/api\/faa/, ''),
|
|
configure: (proxy) => {
|
|
proxy.on('error', (err) => {
|
|
console.log('FAA NASSTATUS proxy error:', err.message);
|
|
});
|
|
},
|
|
},
|
|
// OpenSky Network - Aircraft tracking (military flight detection)
|
|
'/api/opensky': {
|
|
target: 'https://opensky-network.org/api',
|
|
changeOrigin: true,
|
|
secure: true,
|
|
rewrite: (path) => path.replace(/^\/api\/opensky/, ''),
|
|
configure: (proxy) => {
|
|
proxy.on('error', (err) => {
|
|
console.log('OpenSky proxy error:', err.message);
|
|
});
|
|
},
|
|
},
|
|
// ADS-B Exchange - Military aircraft tracking (backup/supplement)
|
|
'/api/adsb-exchange': {
|
|
target: 'https://adsbexchange.com/api',
|
|
changeOrigin: true,
|
|
secure: true,
|
|
rewrite: (path) => path.replace(/^\/api\/adsb-exchange/, ''),
|
|
configure: (proxy) => {
|
|
proxy.on('error', (err) => {
|
|
console.log('ADS-B Exchange proxy error:', err.message);
|
|
});
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|