Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,30 @@ OPENSKY_CLIENT_SECRET=
# Server-side URL (https://) — used by Vercel edge functions to reach the relay
WS_RELAY_URL=

# Client-side URL (wss://) — used by the browser to connect via WebSocket
# Optional client-side URL (wss://) — local/dev fallback only
VITE_WS_RELAY_URL=

# Shared secret between Vercel and Railway relay.
# Must be set to the SAME value on both platforms in production.
RELAY_SHARED_SECRET=

# Header name used to send the relay secret (must match on both platforms)
RELAY_AUTH_HEADER=x-relay-key

# Emergency production override to allow unauthenticated relay traffic.
# Leave unset/false in production.
ALLOW_UNAUTHENTICATED_RELAY=false

# Rolling window size (seconds) used by relay /metrics endpoint.
RELAY_METRICS_WINDOW_SECONDS=60


# ------ Public Data Sources (no keys required) ------

# UCDP (Uppsala Conflict Data Program) — public API, no auth
# UNHCR (UN Refugee Agency) — public API, no auth (CC BY 4.0)
# Open-Meteo — public API, no auth (processes Copernicus ERA5)
# WorldPop — public API, optional key for higher rate limits
# WORLDPOP_API_KEY=
# WorldPop — public API, no auth needed


# ------ Site Configuration ------
Expand Down
88 changes: 88 additions & 0 deletions api/ais-snapshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';

export const config = { runtime: 'edge' };

function getRelayBaseUrl() {
const relayUrl = process.env.WS_RELAY_URL;
if (!relayUrl) return null;
return relayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, '');
}

function getRelayHeaders(baseHeaders = {}) {
const headers = { ...baseHeaders };
const relaySecret = process.env.RELAY_SHARED_SECRET || '';
if (relaySecret) {
const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase();
headers[relayHeader] = relaySecret;
headers.Authorization = `Bearer ${relaySecret}`;
}
return headers;
}

async function fetchWithTimeout(url, options, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}

export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');

if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

const relayBaseUrl = getRelayBaseUrl();
if (!relayBaseUrl) {
return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

try {
const requestUrl = new URL(req.url);
const relayUrl = `${relayBaseUrl}/ais/snapshot${requestUrl.search || ''}`;
const response = await fetchWithTimeout(relayUrl, {
headers: getRelayHeaders({ Accept: 'application/json' }),
}, 12000);

const body = await response.text();
const headers = {
'Content-Type': response.headers.get('content-type') || 'application/json',
'Cache-Control': response.headers.get('cache-control') || 'no-cache',
...corsHeaders,
};

return new Response(body, {
status: response.status,
headers,
});
} catch (error) {
const isTimeout = error?.name === 'AbortError';
return new Response(JSON.stringify({
error: isTimeout ? 'Relay timeout' : 'Relay request failed',
details: error?.message || String(error),
}), {
status: isTimeout ? 504 : 502,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
90 changes: 90 additions & 0 deletions api/opensky.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';

export const config = { runtime: 'edge' };

function getRelayBaseUrl() {
const relayUrl = process.env.WS_RELAY_URL;
if (!relayUrl) return null;
return relayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, '');
}

function getRelayHeaders(baseHeaders = {}) {
const headers = { ...baseHeaders };
const relaySecret = process.env.RELAY_SHARED_SECRET || '';
if (relaySecret) {
const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase();
headers[relayHeader] = relaySecret;
headers.Authorization = `Bearer ${relaySecret}`;
}
return headers;
}

async function fetchWithTimeout(url, options, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}

export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');

if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

const relayBaseUrl = getRelayBaseUrl();
if (!relayBaseUrl) {
return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

try {
const requestUrl = new URL(req.url);
const relayUrl = `${relayBaseUrl}/opensky${requestUrl.search || ''}`;
const response = await fetchWithTimeout(relayUrl, {
headers: getRelayHeaders({ Accept: 'application/json' }),
});

const body = await response.text();
const headers = {
'Content-Type': response.headers.get('content-type') || 'application/json',
'Cache-Control': response.headers.get('cache-control') || 'no-cache',
...corsHeaders,
};
const xCache = response.headers.get('x-cache');
if (xCache) headers['X-Cache'] = xCache;

return new Response(body, {
status: response.status,
headers,
});
} catch (error) {
const isTimeout = error?.name === 'AbortError';
return new Response(JSON.stringify({
error: isTimeout ? 'Relay timeout' : 'Relay request failed',
details: error?.message || String(error),
}), {
status: isTimeout ? 504 : 502,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
88 changes: 88 additions & 0 deletions api/polymarket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';

export const config = { runtime: 'edge' };

function getRelayBaseUrl() {
const relayUrl = process.env.WS_RELAY_URL;
if (!relayUrl) return null;
return relayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, '');
}

function getRelayHeaders(baseHeaders = {}) {
const headers = { ...baseHeaders };
const relaySecret = process.env.RELAY_SHARED_SECRET || '';
if (relaySecret) {
const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase();
headers[relayHeader] = relaySecret;
headers.Authorization = `Bearer ${relaySecret}`;
}
return headers;
}

async function fetchWithTimeout(url, options, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}

export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');

if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

const relayBaseUrl = getRelayBaseUrl();
if (!relayBaseUrl) {
return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

try {
const requestUrl = new URL(req.url);
const relayUrl = `${relayBaseUrl}/polymarket${requestUrl.search || ''}`;
const response = await fetchWithTimeout(relayUrl, {
headers: getRelayHeaders({ Accept: 'application/json' }),
}, 15000);

const body = await response.text();
const headers = {
'Content-Type': response.headers.get('content-type') || 'application/json',
'Cache-Control': response.headers.get('cache-control') || 'no-cache',
...corsHeaders,
};

return new Response(body, {
status: response.status,
headers,
});
} catch (error) {
const isTimeout = error?.name === 'AbortError';
return new Response(JSON.stringify({
error: isTimeout ? 'Relay timeout' : 'Relay request failed',
details: error?.message || String(error),
}), {
status: isTimeout ? 504 : 502,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
Loading