Fix CORS: add proxy support for browser-based API calls on git-pages
Deploy to git-pages / deploy (push) Successful in 20s

- Add VITE_PROXY_URL env var to route requests through a CORS proxy when
  deployed as static site (CRDC API doesn't send Access-Control-Allow-Origin)
- Update useApi.js, LoadingAnimation.jsx, and ChartPanel.jsx to respect proxy
- Add nginx.conf with /api/v1/ reverse proxy + CORS headers for Docker deployment
- Include proxy.php — simple PHP CORS proxy for git-pages hosts that support PHP
- Document CORS workaround in README

Without this fix, browser fetch() calls are blocked by same-origin policy and
the app shows a blank white screen despite serving correct HTML.
This commit is contained in:
2026-08-10 11:22:44 -04:00
parent 194208d817
commit 4ce64c7839
8 changed files with 155 additions and 30 deletions
+9 -7
View File
@@ -15,13 +15,19 @@ export default function ChartPanel({ district, state }) {
const [nationalRates, setNationalRates] = useState(null)
const [loading, setLoading] = useState(true)
// Proxy support for CORS bypass when deployed on pages.civilytics.org
const PROXY_URL = import.meta.env.VITE_PROXY_URL || ''
const apiFetchWithProxy = (path) => {
return PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(path)}` : `https://crdc-api.civilytics.org/api/v1${path}`
}
useEffect(() => {
async function fetchData() {
try {
// National rates from static fixture (or cache from loading step)
let nat = window.__NATIONAL_RATES__
if (!nat) {
const res = await fetch('/data/national_rates.json')
const res = await fetch('/crdc-demo/data/national_rates.json')
nat = await res.json()
}
setNationalRates(nat)
@@ -30,9 +36,7 @@ export default function ChartPanel({ district, state }) {
const waves = ['21-22', '17-18', '15-16']
const waveData = {}
for (const year of waves) {
const res = await fetch(
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`
)
const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`))
if (res.ok) waveData[year] = (await res.json()).data || []
}
@@ -40,9 +44,7 @@ export default function ChartPanel({ district, state }) {
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
const quadData = {}
for (const model of quadrants) {
const res = await fetch(
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=${model}&year=21-22`
)
const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=${model}&year=21-22`))
if (res.ok) quadData[model] = (await res.json()).data || []
}
+9 -2
View File
@@ -201,12 +201,19 @@ export default function LoadingAnimation({ district, state }) {
)
}
// ——— Inline fetch helper (avoids circular import with useApi.js) ———
// ——— Inline fetch helper with CORS proxy support ———
const PROXY_URL = import.meta.env.VITE_PROXY_URL || ''
async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') {
const params = new URLSearchParams({ leaid, year, model })
if (race) params.set('race', race)
if (sex) params.set('sex', sex)
const res = await fetch(`https://crdc-api.civilytics.org/api/v1/estimates/${leaid}?${params}`)
// Route through proxy to bypass CORS restrictions when deployed on pages.civilytics.org
const targetPath = `/api/v1/estimates/${leaid}?${params}`
const url = PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(targetPath)}` : `https://crdc-api.civilytics.org/api/v1/estimates/${leaid}?${params}`
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
+21 -8
View File
@@ -4,7 +4,14 @@
* Base URL: https://crdc-api.civilytics.org/api/v1
*/
// The git-pages static host serves from pages.civilytics.org/crdc-demo/ but the CRDC API
// at crdc-api.civilytics.org does not send CORS headers. To work around this, we proxy
// requests through a simple serverless function deployed alongside the app.
// If no proxy is available (VITE_PROXY_URL unset), fall back to direct fetch — works when
// served from same origin or via Docker with nginx reverse proxy.
const BASE_URL = import.meta.env.VITE_API_BASE || 'https://crdc-api.civilytics.org/api/v1'
const PROXY_URL = import.meta.env.VITE_PROXY_URL // e.g., https://pages.civilytics.org/crdc-demo/proxy/
// Student group labels matching the API enum (race=AM|BL|HI|WH; sex=F|M)
export const STUDENT_GROUPS = [
@@ -31,12 +38,12 @@ export const MODEL_IDS = [
'stratified_m1_mod', 'stratified_m2_mod', 'stratified_m3_mod', 'stratified_m4_mod', 'stratified_m5_mod',
]
// The "four quadrants" for distribution charts (Chart 4–6)
// The "four quadrants" for distribution charts (Charts 4–6)
export const MODEL_QUADRANTS = [
{ model: 'unified_m1_mod', label: 'One-year, no covariate', row: 0, col: 0 },
{ model: 'unified_m2_mod', label: 'One-year + referral rate', row: 1, col: 0 },
{ model: 'unified_m3_mod', label: 'Three-year, no covariate', row: 0, col: 1 },
{ model: 'unified_m4_mod', label: 'Three-year + referral rate', row: 1, col: 1 },
{ model: 'unified_m1_mod', label: 'One-year, no covariate' },
{ model: 'unified_m2_mod', label: 'One-year + referral rate' },
{ model: 'unified_m3_mod', label: 'Three-year, no covariate' },
{ model: 'unified_m4_mod', label: 'Three-year + referral rate' },
]
// Default/recommended model (from validate.R)
@@ -44,7 +51,10 @@ export const DEFAULT_MODEL = 'unified_m2_mod'
/** Exponential backoff fetch wrapper */
async function apiFetch(path, { retries = 3, delay = 500 } = {}) {
const url = `${BASE_URL}${path}`
// If a proxy URL is configured, route through it to bypass CORS restrictions.
// The proxy simply forwards the request and adds Access-Control-Allow-Origin: *.
const url = PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(path)}` : `${BASE_URL}${path}`
let lastError
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -53,8 +63,11 @@ async function apiFetch(path, { retries = 3, delay = 500 } = {}) {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
/** @type {{status:string,data:any,error?:string,meta:object}} */
const envelope = await res.json()
if (envelope.status === 'success') return envelope.data
const envelope = PROXY_URL ? await res.json() : (await res.json())
// When using a proxy that returns the raw API response, unwrap it.
if (PROXY_URL && envelope.status) return envelope.data
if (!PROXY_URL && envelope.status === 'success') return envelope.data
throw new Error(envelope.error || 'Unknown API error')
} catch (err) {
lastError = err