perf(rss): route RSS direct to Railway, skip Vercel middleman (#961)

* perf(rss): route RSS direct to Railway, skip Vercel middleman

Vercel /api/rss-proxy has 65% error rate (207K failed invocations/12h).
Route browser RSS requests directly to Railway (proxy.worldmonitor.app)
via Cloudflare CDN, eliminating Vercel as middleman.

- Add VITE_RSS_DIRECT_TO_RELAY feature flag (default off) for staged rollout
- Centralize RSS proxy URL in rssProxyUrl() with desktop/dev/prod routing
- Make Railway /rss public (skip auth, keep rate limiting with CF-Connecting-IP)
- Add wildcard *.worldmonitor.app CORS + always emit Vary: Origin on /rss
- Extract ~290 RSS domains to shared/rss-allowed-domains.cjs (single source of truth)
- Convert Railway domain check to Set for O(1) lookups
- Remove rss-proxy from KEYED_CLOUD_API_PATTERN (no longer needs API key header)
- Add edge function test for shared domain list import

* fix(edge): replace node:module with JSON import for edge-compatible RSS domains

api/_rss-allowed-domains.js used createRequire from node:module which is
unsupported in Vercel Edge Runtime, breaking all edge functions (including
api/gpsjam). Replaced with JSON import attribute syntax that works in both
esbuild (Vercel build) and Node.js 22+ (tests).

Also fixed middleware.ts TS18048 error where VARIANT_OG[variant] could be
undefined.

* test(edge): add guard against node: built-in imports in api/ files

Scans ALL api/*.js files (including _ helpers) for node: module imports
which are unsupported in Vercel Edge Runtime. This would have caught the
createRequire(node:module) bug before it reached Vercel.

* fix(edge): inline domain array and remove NextResponse reference

- Replace `import ... with { type: 'json' }` in _rss-allowed-domains.js
  with inline array — Vercel esbuild doesn't support import attributes
- Replace `NextResponse.next()` with bare `return` in middleware.ts —
  NextResponse was never imported

* ci(pre-push): add esbuild bundle check and edge function tests

The pre-push hook now catches Vercel build failures locally:
- esbuild bundles each api/*.js entrypoint (catches import attribute
  syntax, missing modules, and other bundler errors)
- runs edge function test suite (node: imports, module isolation)
This commit is contained in:
Elie Habib
2026-03-04 18:42:00 +04:00
committed by GitHub
parent 8fd732be1f
commit 898ac7b1c4
15 changed files with 640 additions and 339 deletions

View File

@@ -9,5 +9,18 @@ for f in scripts/*.cjs; do
[ -f "$f" ] && node -c "$f" || exit 1
done
echo "Running edge function bundle check..."
for f in api/*.js; do
case "$(basename "$f")" in _*) continue;; esac
npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null 2>/dev/null || {
echo "ERROR: esbuild failed to bundle $f — this will break Vercel deployment"
npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null
exit 1
}
done
echo "Running edge function tests..."
node --test tests/edge-functions.test.mjs || exit 1
echo "Running version sync check..."
npm run version:check || exit 1

269
api/_rss-allowed-domains.js Normal file
View File

@@ -0,0 +1,269 @@
// Edge-compatible ESM wrapper for shared RSS allowed domains.
// Source of truth: shared/rss-allowed-domains.json
// NOTE: Cannot use `import ... with { type: 'json' }` — Vercel esbuild doesn't support import attributes.
export default [
"feeds.bbci.co.uk",
"www.theguardian.com",
"feeds.npr.org",
"news.google.com",
"www.aljazeera.com",
"www.aljazeera.net",
"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",
"rsshub.app",
"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",
"www.zdnet.com",
"www.techmeme.com",
"www.darkreading.com",
"www.schneier.com",
"www.ransomware.live",
"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",
"asharqbusiness.com",
"asharq.com",
"www.omanobserver.om",
"english.alarabiya.net",
"www.arabnews.com",
"www.timesofisrael.com",
"www.haaretz.com",
"www.scmp.com",
"kyivindependent.com",
"www.themoscowtimes.com",
"feeds.24.com",
"feeds.news24.com",
"feeds.capi24.com",
"www.france24.com",
"www.euronews.com",
"de.euronews.com",
"es.euronews.com",
"fr.euronews.com",
"it.euronews.com",
"pt.euronews.com",
"ru.euronews.com",
"www.lemonde.fr",
"rss.dw.com",
"www.bild.de",
"www.africanews.com",
"fr.africanews.com",
"www.premiumtimesng.com",
"www.vanguardngr.com",
"www.channelstv.com",
"dailytrust.com",
"www.thisdaylive.com",
"www.naftemporiki.gr",
"www.in.gr",
"www.iefimerida.gr",
"www.lasillavacia.com",
"www.channelnewsasia.com",
"japantoday.com",
"www.thehindu.com",
"indianexpress.com",
"www.twz.com",
"gcaptain.com",
"news.un.org",
"www.iaea.org",
"www.who.int",
"www.cisa.gov",
"www.crisisgroup.org",
"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",
"www.fao.org",
"worldbank.org",
"www.imf.org",
"www.bbc.com",
"www.spiegel.de",
"www.tagesschau.de",
"newsfeed.zeit.de",
"feeds.elpais.com",
"e00-elmundo.uecdn.es",
"www.repubblica.it",
"www.ansa.it",
"xml2.corriereobjects.it",
"feeds.nos.nl",
"www.nrc.nl",
"www.telegraaf.nl",
"www.dn.se",
"www.svd.se",
"www.svt.se",
"www.asahi.com",
"www.clarin.com",
"oglobo.globo.com",
"feeds.folha.uol.com.br",
"www.eltiempo.com",
"www.eluniversal.com.mx",
"www.jeuneafrique.com",
"www.lorientlejour.com",
"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",
"islandtimes.org",
"www.brasilparalelo.com.br",
"mexiconewsdaily.com",
"insightcrime.org",
"www.primicias.ec",
"www.infobae.com",
"www.eluniverso.com",
"news.ycombinator.com",
"www.coindesk.com",
"cointelegraph.com",
"travel.state.gov",
"smartraveller.gov.au",
"www.smartraveller.gov.au",
"www.safetravel.govt.nz",
"th.usembassy.gov",
"ae.usembassy.gov",
"de.usembassy.gov",
"ua.usembassy.gov",
"mx.usembassy.gov",
"in.usembassy.gov",
"pk.usembassy.gov",
"co.usembassy.gov",
"pl.usembassy.gov",
"bd.usembassy.gov",
"it.usembassy.gov",
"do.usembassy.gov",
"mm.usembassy.gov",
"wwwnc.cdc.gov",
"www.ecdc.europa.eu",
"www.afro.who.int",
"www.goodnewsnetwork.org",
"www.positive.news",
"reasonstobecheerful.world",
"www.optimistdaily.com",
"www.upworthy.com",
"www.dailygood.org",
"www.goodgoodgood.co",
"www.good.is",
"www.sunnyskyz.com",
"thebetterindia.com",
"singularityhub.com",
"humanprogress.org",
"greatergood.berkeley.edu",
"www.onlygoodnewsdaily.com",
"www.sciencedaily.com",
"feeds.nature.com",
"www.nature.com",
"www.livescience.com",
"www.newscientist.com",
"www.pbs.org",
"feeds.abcnews.com",
"feeds.nbcnews.com",
"www.cbsnews.com",
"moxie.foxnews.com",
"feeds.content.dowjones.io",
"thehill.com"
];

View File

@@ -2,6 +2,7 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { validateApiKey } from './_api-key.js';
import { checkRateLimit } from './_rate-limit.js';
import { getRelayBaseUrl, getRelayHeaders, fetchWithTimeout } from './_relay.js';
import RSS_ALLOWED_DOMAINS from './_rss-allowed-domains.js';
export const config = { runtime: 'edge' };
@@ -40,296 +41,8 @@ async function fetchViaRailway(feedUrl, timeoutMs) {
}, timeoutMs);
}
// Allowed RSS feed domains for security
const ALLOWED_DOMAINS = [
'feeds.bbci.co.uk',
'www.theguardian.com',
'feeds.npr.org',
'news.google.com',
'www.aljazeera.com',
'www.aljazeera.net',
'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',
'rsshub.app',
'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 domains
'www.zdnet.com',
'www.techmeme.com',
'www.darkreading.com',
'www.schneier.com',
'www.ransomware.live',
'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',
// Additional tech variant domains
'www.producthunt.com',
'www.axios.com',
'api.axios.com',
'github.blog',
'githubnext.com',
'mshibanami.github.io',
'www.engadget.com',
'news.mit.edu',
'dev.events',
// VC blogs
'www.ycombinator.com',
'a16z.com',
'review.firstround.com',
'www.sequoiacap.com',
'www.nfx.com',
'www.aaronsw.com',
'bothsidesofthetable.com',
'www.lennysnewsletter.com',
'stratechery.com',
// Regional startup news
'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',
// Funding & VC
'pitchbook.com',
'www.cbinsights.com',
// Accelerators
'www.techstars.com',
// Middle East & Regional News
'asharqbusiness.com',
'asharq.com',
'www.omanobserver.om',
'english.alarabiya.net',
'www.arabnews.com',
'www.timesofisrael.com',
'www.haaretz.com',
'www.scmp.com',
'kyivindependent.com',
'www.themoscowtimes.com',
'feeds.24.com',
'feeds.news24.com', // News24 main feed domain
'feeds.capi24.com', // News24 redirect destination
// International News Sources
'www.france24.com',
'www.euronews.com',
'de.euronews.com',
'es.euronews.com',
'fr.euronews.com',
'it.euronews.com',
'pt.euronews.com',
'ru.euronews.com',
'www.lemonde.fr',
'rss.dw.com',
'www.bild.de',
'www.africanews.com',
'fr.africanews.com',
// Nigeria
'www.premiumtimesng.com',
'www.vanguardngr.com',
'www.channelstv.com',
'dailytrust.com',
'www.thisdaylive.com',
// Greek
'www.naftemporiki.gr',
'www.in.gr',
'www.iefimerida.gr',
'www.lasillavacia.com',
'www.channelnewsasia.com',
'japantoday.com',
'www.thehindu.com',
'indianexpress.com',
'www.twz.com',
'gcaptain.com',
// International Organizations
'news.un.org',
'www.iaea.org',
'www.who.int',
'www.cisa.gov',
'www.crisisgroup.org',
// Think Tanks & Research (Added 2026-01-29)
'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',
// International news (various languages)
'www.bbc.com',
'www.spiegel.de',
'www.tagesschau.de',
'newsfeed.zeit.de',
'feeds.elpais.com',
'e00-elmundo.uecdn.es',
'www.repubblica.it',
'www.ansa.it',
'xml2.corriereobjects.it',
'feeds.nos.nl',
'www.nrc.nl',
'www.telegraaf.nl',
'www.dn.se',
'www.svd.se',
'www.svt.se',
'www.asahi.com',
'www.clarin.com',
'oglobo.globo.com',
'feeds.folha.uol.com.br',
'www.eltiempo.com',
'www.eluniversal.com.mx',
'www.jeuneafrique.com',
'www.lorientlejour.com',
// Regional locale feeds (tr, pl, ru, th, vi, pt)
'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',
'islandtimes.org',
'www.brasilparalelo.com.br',
// Mexico & LatAm Security
'mexiconewsdaily.com',
'insightcrime.org',
'www.primicias.ec',
'www.infobae.com',
'www.eluniverso.com',
// Additional
'news.ycombinator.com',
// Finance variant
'seekingalpha.com',
'www.coindesk.com',
'cointelegraph.com',
// Security advisories — government travel advisory feeds
'travel.state.gov',
'www.safetravel.govt.nz',
// US Embassy security alerts
'th.usembassy.gov',
'ae.usembassy.gov',
'de.usembassy.gov',
'ua.usembassy.gov',
'mx.usembassy.gov',
'in.usembassy.gov',
'pk.usembassy.gov',
'co.usembassy.gov',
'pl.usembassy.gov',
'bd.usembassy.gov',
'it.usembassy.gov',
'do.usembassy.gov',
'mm.usembassy.gov',
// Health advisories
'wwwnc.cdc.gov',
'www.ecdc.europa.eu',
'www.who.int',
'www.afro.who.int',
// Happy variant — positive news sources
'www.goodnewsnetwork.org',
'www.positive.news',
'reasonstobecheerful.world',
'www.optimistdaily.com',
'www.upworthy.com',
'www.dailygood.org',
'www.goodgoodgood.co',
'www.good.is',
'www.sunnyskyz.com',
'thebetterindia.com',
'singularityhub.com',
'humanprogress.org',
'greatergood.berkeley.edu',
'www.onlygoodnewsdaily.com',
'www.sciencedaily.com',
'feeds.nature.com',
'www.nature.com',
'www.livescience.com',
'www.newscientist.com',
// US broadcast & print news
'www.pbs.org',
'feeds.abcnews.com',
'feeds.nbcnews.com',
'www.cbsnews.com',
'moxie.foxnews.com',
'feeds.content.dowjones.io',
'thehill.com',
];
// Allowed RSS feed domains — shared source of truth (shared/rss-allowed-domains.js)
const ALLOWED_DOMAINS = RSS_ALLOWED_DOMAINS;
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');

View File

@@ -63,7 +63,8 @@ export default function middleware(request: Request) {
if (path === '/' && SOCIAL_PREVIEW_UA.test(ua)) {
const variant = VARIANT_HOST_MAP[host];
if (variant && isAllowedHost(host)) {
const og = VARIANT_OG[variant];
const og = VARIANT_OG[variant] as { title: string; description: string; image: string; url: string } | undefined;
if (!og) return;
const html = `<!DOCTYPE html><html><head>
<meta property="og:type" content="website"/>
<meta property="og:title" content="${og.title}"/>

View File

@@ -17,6 +17,7 @@ const { readFileSync } = require('fs');
const crypto = require('crypto');
const v8 = require('v8');
const { WebSocketServer, WebSocket } = require('ws');
const RSS_ALLOWED_DOMAINS = new Set(require('../shared/rss-allowed-domains.cjs'));
// Log effective heap limit at startup (verifies NODE_OPTIONS=--max-old-space-size is active)
const _heapStats = v8.getHeapStatistics();
@@ -2199,7 +2200,15 @@ function gzipSyncBuffer(body) {
}
}
function getClientIp(req) {
function getClientIp(req, isPublic = false) {
if (isPublic) {
// Public routes: only trust CF-Connecting-IP (set by Cloudflare, not spoofable).
// x-real-ip is excluded — client-spoofable on unauthenticated endpoints.
const cfIp = req.headers['cf-connecting-ip'];
if (typeof cfIp === 'string' && cfIp.trim()) return cfIp.trim();
return req.socket?.remoteAddress || 'unknown';
}
// Authenticated routes: x-real-ip is safe because auth token validates the caller
const xRealIp = req.headers['x-real-ip'];
if (typeof xRealIp === 'string' && xRealIp.trim()) {
return xRealIp.trim();
@@ -2207,7 +2216,6 @@ function getClientIp(req) {
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string' && xff) {
const parts = xff.split(',').map((part) => part.trim()).filter(Boolean);
// Proxy chain order is client,proxy1,proxy2...; use first hop as client IP.
if (parts.length > 0) return parts[0];
}
return req.socket?.remoteAddress || 'unknown';
@@ -2258,12 +2266,12 @@ function getRateLimitForPath(pathname) {
return RELAY_RATE_LIMIT_MAX;
}
function consumeRateLimit(req, pathname) {
function consumeRateLimit(req, pathname, isPublic = false) {
const maxRequests = getRateLimitForPath(pathname);
if (!Number.isFinite(maxRequests) || maxRequests <= 0) return { limited: false, limit: 0, remaining: 0, resetInMs: 0 };
const now = Date.now();
const ip = getClientIp(req);
const ip = getClientIp(req, isPublic);
const key = `${getRouteGroup(pathname)}:${ip}`;
const existing = requestRateBuckets.get(key);
if (!existing || now >= existing.resetAt) {
@@ -4363,6 +4371,11 @@ const ALLOWED_ORIGINS = [
function getCorsOrigin(req) {
const origin = req.headers.origin || '';
if (ALLOWED_ORIGINS.includes(origin)) return origin;
// Wildcard: any *.worldmonitor.app subdomain (for variant subdomains)
try {
const url = new URL(origin);
if (url.hostname.endsWith('.worldmonitor.app') && url.protocol === 'https:') return origin;
} catch { /* invalid origin — fall through */ }
// Optional: allow Vercel preview deployments when explicitly enabled.
if (ALLOW_VERCEL_PREVIEW_ORIGINS && origin.endsWith('.vercel.app')) return origin;
return '';
@@ -4371,9 +4384,14 @@ function getCorsOrigin(req) {
const server = http.createServer(async (req, res) => {
const pathname = (req.url || '/').split('?')[0];
const corsOrigin = getCorsOrigin(req);
// Always emit Vary: Origin on /rss (browser-direct via CDN) to prevent
// cached no-CORS responses from being served to browser clients.
const isRssRoute = pathname.startsWith('/rss');
if (corsOrigin) {
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
res.setHeader('Vary', 'Origin');
} else if (isRssRoute) {
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', `Content-Type, Authorization, ${RELAY_AUTH_HEADER}`);
@@ -4388,13 +4406,16 @@ const server = http.createServer(async (req, res) => {
// served to unauthenticated requests from edge cache. This is acceptable — all proxied data
// is public (RSS, WorldBank, UCDP, Polymarket, OpenSky, AIS). Auth exists for abuse
// prevention (rate limiting), not data protection. Cloudflare WAF provides edge-level protection.
const isPublicRoute = pathname === '/health' || pathname === '/';
const isPublicRoute = pathname === '/health' || pathname === '/' || isRssRoute;
if (!isPublicRoute) {
if (!isAuthorizedRequest(req)) {
return safeEnd(res, 401, { 'Content-Type': 'application/json' },
JSON.stringify({ error: 'Unauthorized', time: Date.now() }));
}
const rl = consumeRateLimit(req, pathname);
}
// Rate limiting applies to all non-health routes (including public /rss)
if (pathname !== '/health' && pathname !== '/') {
const rl = consumeRateLimit(req, pathname, isPublicRoute);
if (rl.limited) {
const retryAfterSec = Math.max(1, Math.ceil(rl.resetInMs / 1000));
return safeEnd(res, 429, {
@@ -4620,31 +4641,7 @@ const server = http.createServer(async (req, res) => {
return res.end(JSON.stringify({ error: 'Missing url parameter' }));
}
// Allow domains that block Vercel IPs (must match feeds.ts railwayRss usage)
const allowedDomains = [
// Original
'rss.cnn.com',
'www.defensenews.com',
'layoffs.fyi',
// International Organizations
'news.un.org',
'www.cisa.gov',
'www.iaea.org',
'www.who.int',
'www.crisisgroup.org',
// Middle East & Regional News
'english.alarabiya.net',
'www.arabnews.com',
'www.timesofisrael.com',
'www.scmp.com',
'kyivindependent.com',
'www.themoscowtimes.com',
// Africa
'feeds.24.com',
'feeds.capi24.com', // News24 redirect destination
'islandtimes.org',
'www.atlanticcouncil.org',
];
// Domain allowlist from shared source of truth (shared/rss-allowed-domains.js)
const parsed = new URL(feedUrl);
// Block deprecated/stale feed domains — stale clients still request these
const blockedDomains = ['rsshub.app'];
@@ -4652,7 +4649,7 @@ const server = http.createServer(async (req, res) => {
res.writeHead(410, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Feed deprecated' }));
}
if (!allowedDomains.includes(parsed.hostname)) {
if (!RSS_ALLOWED_DOMAINS.has(parsed.hostname)) {
res.writeHead(403, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Domain not allowed on Railway proxy' }));
}

View File

@@ -0,0 +1,2 @@
// CJS wrapper — source of truth is rss-allowed-domains.json
module.exports = require('./rss-allowed-domains.json');

View File

@@ -0,0 +1,266 @@
[
"feeds.bbci.co.uk",
"www.theguardian.com",
"feeds.npr.org",
"news.google.com",
"www.aljazeera.com",
"www.aljazeera.net",
"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",
"rsshub.app",
"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",
"www.zdnet.com",
"www.techmeme.com",
"www.darkreading.com",
"www.schneier.com",
"www.ransomware.live",
"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",
"asharqbusiness.com",
"asharq.com",
"www.omanobserver.om",
"english.alarabiya.net",
"www.arabnews.com",
"www.timesofisrael.com",
"www.haaretz.com",
"www.scmp.com",
"kyivindependent.com",
"www.themoscowtimes.com",
"feeds.24.com",
"feeds.news24.com",
"feeds.capi24.com",
"www.france24.com",
"www.euronews.com",
"de.euronews.com",
"es.euronews.com",
"fr.euronews.com",
"it.euronews.com",
"pt.euronews.com",
"ru.euronews.com",
"www.lemonde.fr",
"rss.dw.com",
"www.bild.de",
"www.africanews.com",
"fr.africanews.com",
"www.premiumtimesng.com",
"www.vanguardngr.com",
"www.channelstv.com",
"dailytrust.com",
"www.thisdaylive.com",
"www.naftemporiki.gr",
"www.in.gr",
"www.iefimerida.gr",
"www.lasillavacia.com",
"www.channelnewsasia.com",
"japantoday.com",
"www.thehindu.com",
"indianexpress.com",
"www.twz.com",
"gcaptain.com",
"news.un.org",
"www.iaea.org",
"www.who.int",
"www.cisa.gov",
"www.crisisgroup.org",
"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",
"www.fao.org",
"worldbank.org",
"www.imf.org",
"www.bbc.com",
"www.spiegel.de",
"www.tagesschau.de",
"newsfeed.zeit.de",
"feeds.elpais.com",
"e00-elmundo.uecdn.es",
"www.repubblica.it",
"www.ansa.it",
"xml2.corriereobjects.it",
"feeds.nos.nl",
"www.nrc.nl",
"www.telegraaf.nl",
"www.dn.se",
"www.svd.se",
"www.svt.se",
"www.asahi.com",
"www.clarin.com",
"oglobo.globo.com",
"feeds.folha.uol.com.br",
"www.eltiempo.com",
"www.eluniversal.com.mx",
"www.jeuneafrique.com",
"www.lorientlejour.com",
"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",
"islandtimes.org",
"www.brasilparalelo.com.br",
"mexiconewsdaily.com",
"insightcrime.org",
"www.primicias.ec",
"www.infobae.com",
"www.eluniverso.com",
"news.ycombinator.com",
"www.coindesk.com",
"cointelegraph.com",
"travel.state.gov",
"smartraveller.gov.au",
"www.smartraveller.gov.au",
"www.safetravel.govt.nz",
"th.usembassy.gov",
"ae.usembassy.gov",
"de.usembassy.gov",
"ua.usembassy.gov",
"mx.usembassy.gov",
"in.usembassy.gov",
"pk.usembassy.gov",
"co.usembassy.gov",
"pl.usembassy.gov",
"bd.usembassy.gov",
"it.usembassy.gov",
"do.usembassy.gov",
"mm.usembassy.gov",
"wwwnc.cdc.gov",
"www.ecdc.europa.eu",
"www.afro.who.int",
"www.goodnewsnetwork.org",
"www.positive.news",
"reasonstobecheerful.world",
"www.optimistdaily.com",
"www.upworthy.com",
"www.dailygood.org",
"www.goodgoodgood.co",
"www.good.is",
"www.sunnyskyz.com",
"thebetterindia.com",
"singularityhub.com",
"humanprogress.org",
"greatergood.berkeley.edu",
"www.onlygoodnewsdaily.com",
"www.sciencedaily.com",
"feeds.nature.com",
"www.nature.com",
"www.livescience.com",
"www.newscientist.com",
"www.pbs.org",
"feeds.abcnews.com",
"feeds.nbcnews.com",
"www.cbsnews.com",
"moxie.foxnews.com",
"feeds.content.dowjones.io",
"thehill.com"
]

View File

@@ -1,12 +1,9 @@
import type { Feed } from '@/types';
import { SITE_VARIANT } from './variant';
import { rssProxyUrl } from '@/utils';
// Helper to create RSS proxy URL (Vercel)
const rss = (url: string) => `/api/rss-proxy?url=${encodeURIComponent(url)}`;
// Keep dedicated alias for feeds historically fetched through Railway.
// `rss-proxy` now handles secure server-side fallback.
const railwayRss = (url: string) => rss(url);
const rss = rssProxyUrl;
const railwayRss = rssProxyUrl;
// Source tier system for prioritization (lower = more authoritative)
// Tier 1: Wire services - fastest, most reliable breaking news

View File

@@ -21,8 +21,9 @@ export {
// Finance-specific FEEDS configuration
import type { Feed } from '@/types';
import { rssProxyUrl } from '@/utils';
const rss = (url: string) => `/api/rss-proxy?url=${encodeURIComponent(url)}`;
const rss = rssProxyUrl;
export const FEEDS: Record<string, Feed[]> = {
// Core Markets & Trading News (all free RSS / Google News proxies)

View File

@@ -24,8 +24,9 @@ export {
// Tech-specific FEEDS configuration
import type { Feed } from '@/types';
import { rssProxyUrl } from '@/utils';
const rss = (url: string) => `/api/rss-proxy?url=${encodeURIComponent(url)}`;
const rss = rssProxyUrl;
export const FEEDS: Record<string, Feed[]> = {
// Core Tech News

View File

@@ -1,7 +1,7 @@
import { SITE_VARIANT } from '@/config/variant';
const WS_API_URL = import.meta.env.VITE_WS_API_URL || '';
const KEYED_CLOUD_API_PATTERN = /^\/api\/(?:[^/]+\/v1\/|bootstrap(?:\?|$)|rss-proxy(?:\?|$)|polymarket(?:\?|$)|ais-snapshot(?:\?|$))/;
const KEYED_CLOUD_API_PATTERN = /^\/api\/(?:[^/]+\/v1\/|bootstrap(?:\?|$)|polymarket(?:\?|$)|ais-snapshot(?:\?|$))/;
const DEFAULT_REMOTE_HOSTS: Record<string, string> = {
tech: WS_API_URL,

View File

@@ -1,13 +1,9 @@
import { isDesktopRuntime } from '@/services/runtime';
import { proxyUrl } from '@/utils';
import { rssProxyUrl } from '@/utils';
import { getPersistentCache, setPersistentCache } from './persistent-cache';
import { dataFreshness } from './data-freshness';
import { nameToCountryCode, matchCountryNamesInText } from './country-geometry';
function advisoryFeedUrl(feedUrl: string): string {
if (isDesktopRuntime()) return proxyUrl(feedUrl);
return `/api/rss-proxy?url=${encodeURIComponent(feedUrl)}`;
}
const advisoryFeedUrl = rssProxyUrl;
export interface SecurityAdvisory {
title: string;

View File

@@ -172,7 +172,7 @@ export function chunkArray<T>(items: T[], size: number): T[][] {
return chunks;
}
export { proxyUrl, fetchWithProxy } from './proxy';
export { proxyUrl, fetchWithProxy, rssProxyUrl } from './proxy';
export { exportToJSON, exportToCSV, ExportPanel } from './export';
export { buildMapUrl, parseMapUrlState } from './urlState';
export type { ParsedMapUrlState } from './urlState';

View File

@@ -4,6 +4,23 @@ import { getPersistentCache, setPersistentCache } from '../services/persistent-c
const isDev = import.meta.env.DEV;
const RESPONSE_CACHE_PREFIX = 'api-response:';
// RSS proxy: route directly to Railway relay via Cloudflare CDN when enabled.
// Feature flag controls rollout; default off for safe staged deployment.
const RSS_DIRECT_TO_RELAY = import.meta.env.VITE_RSS_DIRECT_TO_RELAY === 'true';
const RSS_PROXY_BASE = isDev
? '' // Dev uses Vite's rssProxyPlugin
: RSS_DIRECT_TO_RELAY
? 'https://proxy.worldmonitor.app'
: '';
export function rssProxyUrl(feedUrl: string): string {
if (isDesktopRuntime()) return proxyUrl(feedUrl);
if (RSS_PROXY_BASE) {
return `${RSS_PROXY_BASE}/rss?url=${encodeURIComponent(feedUrl)}`;
}
return `/api/rss-proxy?url=${encodeURIComponent(feedUrl)}`;
}
type CachedResponsePayload = {
url: string;
status: number;

View File

@@ -13,6 +13,34 @@ const edgeFunctions = readdirSync(apiDir)
.filter((f) => f.endsWith('.js') && !f.startsWith('_'))
.map((f) => ({ name: f, path: join(apiDir, f) }));
// ALL .js files in api/ (including helpers) — used for node: built-in checks
const allApiFiles = readdirSync(apiDir)
.filter((f) => f.endsWith('.js'))
.map((f) => ({ name: f, path: join(apiDir, f) }));
describe('Edge Function shared helpers resolve', () => {
it('_rss-allowed-domains.js re-exports shared domain list', async () => {
const mod = await import(join(apiDir, '_rss-allowed-domains.js'));
const domains = mod.default;
assert.ok(Array.isArray(domains), 'Expected default export to be an array');
assert.ok(domains.length > 200, `Expected 200+ domains, got ${domains.length}`);
assert.ok(domains.includes('feeds.bbci.co.uk'), 'Expected BBC feed domain in list');
});
});
describe('Edge Function no node: built-ins', () => {
for (const { name, path } of allApiFiles) {
it(`${name} does not import node: built-ins (unsupported in Vercel Edge Runtime)`, () => {
const src = readFileSync(path, 'utf-8');
const match = src.match(/from\s+['"]node:(\w+)['"]/);
assert.ok(
!match,
`${name}: imports node:${match?.[1]} — Vercel Edge Runtime does not support node: built-in modules. Use an edge-compatible alternative.`,
);
});
}
});
describe('Edge Function module isolation', () => {
for (const { name, path } of edgeFunctions) {
it(`${name} does not import from ../server/ (Edge Functions cannot resolve cross-directory TS)`, () => {