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
91 changes: 91 additions & 0 deletions scripts/ais-relay.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ function getRouteGroup(pathname) {
if (pathname.startsWith('/polymarket')) return 'polymarket';
if (pathname.startsWith('/ucdp-events')) return 'ucdp-events';
if (pathname.startsWith('/oref')) return 'oref';
if (pathname === '/notam') return 'notam';
return 'other';
}

Expand Down Expand Up @@ -2531,6 +2532,94 @@ setInterval(() => {
}
}, 5 * 60 * 1000);

// ─────────────────────────────────────────────────────────────
// NOTAM proxy — ICAO API times out from Vercel edge, relay proxies
// ─────────────────────────────────────────────────────────────
const ICAO_API_KEY = process.env.ICAO_API_KEY;
const notamCache = { data: null, ts: 0 };
const NOTAM_CACHE_TTL = 30 * 60 * 1000; // 30 min

function handleNotamProxyRequest(req, res) {
const url = new URL(req.url, `http://localhost:${PORT}`);
const locations = url.searchParams.get('locations');
if (!locations) {
return sendCompressed(req, res, 400, { 'Content-Type': 'application/json' },
JSON.stringify({ error: 'Missing locations parameter' }));
}
if (!ICAO_API_KEY) {
return sendCompressed(req, res, 200, { 'Content-Type': 'application/json' },
JSON.stringify([]));
}

const cacheKey = locations.split(',').sort().join(',');
if (notamCache.data && notamCache.key === cacheKey && Date.now() - notamCache.ts < NOTAM_CACHE_TTL) {
return sendCompressed(req, res, 200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800, s-maxage=1800',
'X-Cache': 'HIT',
}, notamCache.data);
}

const apiUrl = `https://dataservices.icao.int/api/notams-realtime-list?api_key=${ICAO_API_KEY}&format=json&locations=${encodeURIComponent(locations)}`;

const request = https.get(apiUrl, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' },
timeout: 25000,
}, (upstream) => {
if (upstream.statusCode !== 200) {
console.warn(`[Relay] NOTAM upstream HTTP ${upstream.statusCode}`);
upstream.resume();
return sendCompressed(req, res, 200, { 'Content-Type': 'application/json' },
JSON.stringify([]));
}
const ct = upstream.headers['content-type'] || '';
if (ct.includes('text/html')) {
console.warn('[Relay] NOTAM upstream returned HTML (challenge page)');
upstream.resume();
return sendCompressed(req, res, 200, { 'Content-Type': 'application/json' },
JSON.stringify([]));
}
const chunks = [];
upstream.on('data', c => chunks.push(c));
upstream.on('end', () => {
const body = Buffer.concat(chunks).toString();
try {
JSON.parse(body); // validate JSON
notamCache.data = body;
notamCache.key = cacheKey;
notamCache.ts = Date.now();
console.log(`[Relay] NOTAM: ${body.length} bytes for ${locations}`);
sendCompressed(req, res, 200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800, s-maxage=1800',
'X-Cache': 'MISS',
}, body);
} catch {
console.warn('[Relay] NOTAM: invalid JSON response');
sendCompressed(req, res, 200, { 'Content-Type': 'application/json' },
JSON.stringify([]));
}
});
});

request.on('error', (err) => {
console.warn(`[Relay] NOTAM error: ${err.message}`);
if (!res.headersSent) {
sendCompressed(req, res, 200, { 'Content-Type': 'application/json' },
JSON.stringify([]));
}
});

request.on('timeout', () => {
request.destroy();
console.warn('[Relay] NOTAM timeout (25s)');
if (!res.headersSent) {
sendCompressed(req, res, 200, { 'Content-Type': 'application/json' },
JSON.stringify([]));
}
});
}

// CORS origin allowlist — only our domains can use this relay
const ALLOWED_ORIGINS = [
'https://worldmonitor.app',
Expand Down Expand Up @@ -3015,6 +3104,8 @@ const server = http.createServer(async (req, res) => {
handlePolymarketRequest(req, res);
} else if (pathname === '/youtube-live') {
handleYouTubeLiveRequest(req, res);
} else if (pathname === '/notam') {
handleNotamProxyRequest(req, res);
} else {
res.writeHead(404);
res.end();
Expand Down
112 changes: 79 additions & 33 deletions server/worldmonitor/aviation/v1/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,62 +350,108 @@ export interface NotamClosureResult {
notamsByIcao: Map<string, string>;
}

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

function getRelayHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'User-Agent': CHROME_UA };
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;
}

export async function fetchNotamClosures(
airports: MonitoredAirport[]
): Promise<NotamClosureResult> {
const apiKey = process.env.ICAO_API_KEY;
const result: NotamClosureResult = { closedIcaoCodes: new Set(), notamsByIcao: new Map() };
if (!apiKey) return result;
if (!apiKey) {
console.log('[Aviation] NOTAM: no ICAO_API_KEY — skipping');
return result;
}

const relayBase = getRelayBaseUrl();
const icaoCodes = airports.map(a => a.icao);
const batchSize = 20;
const now = Math.floor(Date.now() / 1000);

for (let i = 0; i < icaoCodes.length; i += batchSize) {
const batch = icaoCodes.slice(i, i + batchSize);
const locations = batch.join(',');
try {
// Send all locations in one request (relay or direct)
const locations = icaoCodes.join(',');
let notams: IcaoNotam[] = [];

try {
if (relayBase) {
// Route through Railway relay — avoids Vercel edge timeout / CloudFront blocking
const relayUrl = `${relayBase}/notam?locations=${encodeURIComponent(locations)}`;
console.log(`[Aviation] NOTAM: fetching via relay for ${icaoCodes.length} airports`);
const resp = await fetch(relayUrl, {
headers: getRelayHeaders(),
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) {
console.warn(`[Aviation] NOTAM relay: HTTP ${resp.status}`);
return result;
}
const data = await resp.json();
if (Array.isArray(data)) notams = data;
} else {
// Direct ICAO call (slower from Vercel, may timeout)
console.log(`[Aviation] NOTAM: fetching direct for ${icaoCodes.length} airports`);
const url = `${ICAO_NOTAM_URL}?api_key=${apiKey}&format=json&locations=${locations}`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(10_000),
signal: AbortSignal.timeout(20_000),
});
if (!resp.ok) {
console.warn(`[Aviation] NOTAM batch ${i / batchSize + 1}: HTTP ${resp.status}`);
continue;
console.warn(`[Aviation] NOTAM direct: HTTP ${resp.status}`);
return result;
}
const contentType = resp.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
console.warn(`[Aviation] NOTAM batch ${i / batchSize + 1}: got HTML instead of JSON (API may be down or key invalid)`);
continue;
}
const notams = await resp.json() as IcaoNotam[];
if (!Array.isArray(notams)) continue;

for (const n of notams) {
const icao = n.itema || n.location || '';
if (!icao || !batch.includes(icao)) continue;
if (n.endvalidity && n.endvalidity < now) continue;

const code23 = (n.code23 || '').toUpperCase();
const code45 = (n.code45 || '').toUpperCase();
const text = (n.iteme || '').toUpperCase();
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) &&
(code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW');
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);

if (isClosureCode || isClosureText) {
result.closedIcaoCodes.add(icao);
result.notamsByIcao.set(icao, n.iteme || 'Airport closure (NOTAM)');
}
console.warn('[Aviation] NOTAM direct: got HTML instead of JSON');
return result;
}
} catch (err) {
console.warn(`[Aviation] NOTAM batch ${i / batchSize + 1}: ${err instanceof Error ? err.message : 'unknown'}`);
const data = await resp.json();
if (Array.isArray(data)) notams = data;
}
} catch (err) {
console.warn(`[Aviation] NOTAM fetch: ${err instanceof Error ? err.message : 'unknown'}`);
return result;
}

console.log(`[Aviation] NOTAM: ${notams.length} raw NOTAMs received`);

for (const n of notams) {
const icao = n.itema || n.location || '';
if (!icao || !icaoCodes.includes(icao)) continue;
if (n.endvalidity && n.endvalidity < now) continue;

const code23 = (n.code23 || '').toUpperCase();
const code45 = (n.code45 || '').toUpperCase();
const text = (n.iteme || '').toUpperCase();
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) &&
(code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW');
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);

if (isClosureCode || isClosureText) {
result.closedIcaoCodes.add(icao);
result.notamsByIcao.set(icao, n.iteme || 'Airport closure (NOTAM)');
}
}

if (result.closedIcaoCodes.size > 0) {
console.log(`[Aviation] NOTAM closures: ${[...result.closedIcaoCodes].join(', ')}`);
} else {
console.log('[Aviation] NOTAM: no closures found');
}
return result;
}
Expand Down
3 changes: 3 additions & 0 deletions server/worldmonitor/aviation/v1/list-airport-delays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export async function listAirportDelays(
const cached = await getCachedJson(INTL_CACHE_KEY);
if (cached && typeof cached === 'object' && 'alerts' in (cached as Record<string, unknown>)) {
intlAlerts = (cached as { alerts: AirportDelayAlert[] }).alerts;
const simCount = intlAlerts.filter(a => a.id.startsWith('sim-')).length;
const realCount = intlAlerts.length - simCount;
console.log(`[Aviation] Intl cache HIT: ${intlAlerts.length} alerts (${realCount} real, ${simCount} simulated)`);
} else {
const nonUs = MONITORED_AIRPORTS.filter(a => a.country !== 'USA');
const apiKey = process.env.AVIATIONSTACK_API;
Expand Down