DEV Community

K. Bear
K. Bear

Posted on

I built a global conflict monitor in a single HTML file — here's how the serverless architecture works

When I started building CrisisPulse, I had one constraint: it had to work without a backend database, a framework, or a build pipeline. The result is crisispulse.org — a live global conflict monitor + emergency supply calculator, shipped as a single HTML file.

Here's how the architecture works.

The Stack

Frontend: Pure HTML/CSS/JS with D3.js for the world map. No React, no build step. The entire app ships as one file (~99KB). Zero dependencies to install, zero build times.

Daily news updates: A Netlify Scheduled Function runs @daily, fetching Bing RSS feeds for 25+ conflict zones. It parses article counts to calculate intensity scores and deltas, translates headlines to Chinese via the Google Translate gtx endpoint, and stores everything to Netlify Blobs.

Persistence without a database: Netlify Blobs is a built-in KV store included with Netlify. Visitor counts by country, conflict data, subscriber emails — all stored there. No Postgres, no Redis, no external API keys for storage.

The Tricky Part — Bing RSS URL Decoding

Bing's RSS feeds double-encode their redirect URLs. The <link> tags look like this:

https://www.bing.com/news/apiclick.aspx?url=https%3A%2F%2F...&amp;ref=...
Enter fullscreen mode Exit fullscreen mode

The &amp; breaks new URL() parsing. The fix:

function extractRealUrl(bingUrl) {
  let clean = bingUrl.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  try {
    if (clean.includes('apiclick.aspx') || clean.includes('bing.com/news')) {
      const u = new URL(clean);
      const real = u.searchParams.get('url');
      if (real) return decodeURIComponent(real);
    }
    if (clean.startsWith('http')) return clean;
  } catch (_) {}
  return clean;
}
Enter fullscreen mode Exit fullscreen mode

Decode HTML entities before parsing the URL params. Simple fix, non-obvious bug.

Bilingual Support Without a Translation API Key

A static CONFLICT_ZH map covers all 25 conflict names, types, and descriptions. Dynamic news descriptions get batch-translated via the free Google Translate gtx endpoint (no API key required). Language switching re-runs the risk calculation rather than serving stale cached strings — a subtle bug I hit early on where the cache stored already-translated strings.

Visitor Tracking by Country

Each page load hits a Netlify Function that:

  1. Reads the visitor's country from context.geo (Netlify's built-in geolocation)
  2. Falls back to GPS if available
  3. Increments a per-country counter in Netlify Blobs
  4. Returns the updated counts for the left-side visitor panel

No external analytics, no cookies, no tracking scripts.

What I'd Do Differently

The single-file constraint was a feature, not a limitation. It forced every line to justify its existence. But I'd reconsider using the free Google Translate gtx endpoint in production — it's undocumented and could break without notice.

Live site: crisispulse.org
Free, no signup, supports English and Chinese.

Top comments (1)

Collapse
 
xkbear profile image
K. Bear

Things I'd change in v2 — AMA if you're curious 👇

A few trade-offs I made that I'm still not 100% sure about:

  1. Single HTML file vs. SSR/ISR. Shipping one ~100KB file means zero build step and instant edge cache hits, but the map data + 25 conflicts + i18n strings all load upfront. For first paint on slow connections that's a real cost. Would splitting critical-path JSON from lazy-loaded map assets be worth the complexity? I went with "ship it all" because Brotli on Netlify makes the gzipped payload surprisingly small (~32KB), but the jury's out.

  2. Bing RSS + Google Translate gtx over a real news API. It's free, multilingual, and needs zero API keys — but Bing sometimes returns stale entries and gtx has no SLA. The news freshness SLO is basically "best effort." A paid NewsAPI would fix this at ~$50/mo. For a side project I chose the zero-cost path. Wrong call for a production OSINT tool? Probably.

  3. Netlify Blobs instead of Postgres/SQLite-on-the-edge. Blobs is basically a KV store with no query engine. Fine for visitor counters and RSS snapshot caches, painful the moment you want analytics. I'll hit this wall within a month.

Happy to go deeper on any of these — the code is ~1200 lines of vanilla JS, nothing is hidden. Fire away 🙋