Skip to content

Architecture

Stack

LayerTechnology
DashboardReact + TypeScript, Vite, Redux Toolkit
Reverse proxy / static hostNginx
APIExpress (Node.js)
DatabasePostgreSQL
Cache / pub-sub stateRedis
Realtime transportServer-Sent Events (SSE)
AuthJWT in an httpOnly cookie

Request flow

Browser (dashboard) ──▶ Nginx :80 ──▶ /            → static React build
                                   └▶ /api/         → backend :3000

SDK client (your app) ──▶ backend :3000 directly
                            /api/v1/flags            (x-sdk-key header)
                            /api/v1/stream            (SSE, sdkKey query param)

Nginx (nginx/nginx.conf) serves the built frontend for any non-API path (try_files ... /index.html, so client-side routing works) and proxies everything under /api/ to the backend container, with proxy_buffering off so SSE streams aren't buffered.

Backend routing (backend/app.js)

Routes are mounted in this order:

  1. /api/v1/stream and /api/v1 (SDK + SSE) — behind projectOriginCors, a dynamic CORS middleware (see below). No JWT required — these are keyed by SDK key instead.
  2. Global CORS for the dashboard origins (localhost:5173 / 5174 in dev)
  3. /api/auth — public
  4. /api/projects, /api/environments, /api/flags — behind verifyUser (JWT cookie required)

Dynamic per-project CORS

Unlike a typical static CORS allowlist, FlagPulse's SDK/SSE endpoints accept requests from any origin that's been registered as an environment URL. This is implemented with Redis as the source of truth:

  • On server start, syncCorsOrigins() reads every environment URL from Postgres and seeds a Redis set (cors:origin) with their origins.
  • Creating or editing an environment adds/removes its origin from that same set (addNewOriginToCache / removeOriginFromCache).
  • The projectOriginCors middleware checks incoming request origins against cors:origin via SISMEMBER on every request.
  • On shutdown (SIGINT), the Redis set is cleared and rebuilt fresh on next boot.

Caching strategy

Two things are cached in Redis, both with a 5-minute TTL:

  • SDK key → environment ID (envId@<sdkKey>) — avoids a Postgres lookup on every SDK request.
  • Environment ID → flag list (flags@<environmentId>) — the full evaluated flag set for an environment, as JSON.

Both are read-through: a cache miss falls back to Postgres and repopulates the cache. Writes (flag edits, toggles, deletes, SDK key rotation) proactively invalidate or patch the affected cache entries so SDK reads never serve stale data past a single write.

Realtime updates

backend/services/sse.js keeps two in-memory connection maps:

  • connections: environment ID → list of SDK client response streams (/api/v1/stream)
  • dashboardConnections: environment ID → list of dashboard response streams (/api/v1/stream/dashboard, requires JWT)

When a flag is created, updated, toggled, or deleted, the relevant controller calls sendClient(envId, payload), which writes an SSE data: event to every connected SDK client for that environment. Dashboard connections separately receive presence events with a live count of connected SDK clients whenever one disconnects.

Because connection state is in-memory (not Redis-backed), realtime state does not survive a backend restart or scale beyond a single instance — see Realtime Updates for what this means for SDK clients.

Next: API Reference