mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-13 10:36:21 +02:00
Reduce Vercel execution costs and Upstash Redis read counts by letting the CDN absorb more traffic. Addresses the "double dip" where bootstrap.js hit Redis for 15 keys on every 60s cache miss, and telegram-feed/geo had no s-maxage at all (every request hit Vercel). - bootstrap: s-maxage 60→600, add stale-if-error=900 - ais-snapshot: s-maxage 180→300, stale-while-revalidate 300→600 - oref-alerts: s-maxage 5→180, add stale-if-error=600 - telegram-feed: add s-maxage=600, stale-if-error=900 (had none) - geo: add s-maxage=3600, stale-if-error=3600 (had none) - gpsjam: add stale-if-error=3600 - opensky: add stale-if-error=300 - version: add stale-if-error=3600 - download: add stale-if-error=600
150 lines
4.2 KiB
JavaScript
150 lines
4.2 KiB
JavaScript
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
|
|
|
export const config = { runtime: 'edge' };
|
|
|
|
const REDIS_KEY = 'intelligence:gpsjam:v1';
|
|
const BASE_URL = 'https://gpsjam.org/data';
|
|
const UA = 'Mozilla/5.0 (compatible; WorldMonitor/1.0)';
|
|
const MIN_AIRCRAFT = 3;
|
|
|
|
let cached = null;
|
|
let cachedAt = 0;
|
|
const CACHE_TTL = 3600_000;
|
|
|
|
let inflight = null;
|
|
let negUntil = 0;
|
|
const NEG_TTL = 300_000;
|
|
|
|
async function readFromRedis() {
|
|
const url = process.env.UPSTASH_REDIS_REST_URL;
|
|
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
if (!url || !token) return null;
|
|
|
|
const resp = await fetch(`${url}/get/${encodeURIComponent(REDIS_KEY)}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3_000),
|
|
});
|
|
if (!resp.ok) return null;
|
|
|
|
const data = await resp.json();
|
|
if (!data.result) return null;
|
|
|
|
try { return JSON.parse(data.result); } catch { return null; }
|
|
}
|
|
|
|
async function fetchDirectFromGpsJam() {
|
|
const manifestResp = await fetch(`${BASE_URL}/manifest.csv`, {
|
|
headers: { 'User-Agent': UA },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!manifestResp.ok) throw new Error(`Manifest HTTP ${manifestResp.status}`);
|
|
const manifest = await manifestResp.text();
|
|
const lines = manifest.trim().split('\n');
|
|
const latestDate = lines[lines.length - 1].split(',')[0];
|
|
|
|
const hexResp = await fetch(`${BASE_URL}/${latestDate}-h3_4.csv`, {
|
|
headers: { 'User-Agent': UA },
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!hexResp.ok) throw new Error(`Hex data HTTP ${hexResp.status}`);
|
|
const csv = await hexResp.text();
|
|
const rows = csv.trim().split('\n');
|
|
|
|
const hexes = [];
|
|
for (let i = 1; i < rows.length; i++) {
|
|
const parts = rows[i].split(',');
|
|
if (parts.length < 3) continue;
|
|
const hex = parts[0];
|
|
const good = parseInt(parts[1], 10);
|
|
const bad = parseInt(parts[2], 10);
|
|
const total = good + bad;
|
|
if (total < MIN_AIRCRAFT) continue;
|
|
const pct = (bad / total) * 100;
|
|
let level;
|
|
if (pct > 10) level = 'high';
|
|
else if (pct >= 2) level = 'medium';
|
|
else continue;
|
|
hexes.push({ h3: hex, pct: Math.round(pct * 10) / 10, good, bad, total, level });
|
|
}
|
|
|
|
hexes.sort((a, b) => {
|
|
if (a.level !== b.level) return a.level === 'high' ? -1 : 1;
|
|
return b.pct - a.pct;
|
|
});
|
|
|
|
return {
|
|
date: latestDate,
|
|
fetchedAt: new Date().toISOString(),
|
|
source: 'gpsjam.org',
|
|
stats: {
|
|
totalHexes: rows.length - 1,
|
|
highCount: hexes.filter(h => h.level === 'high').length,
|
|
mediumCount: hexes.filter(h => h.level === 'medium').length,
|
|
},
|
|
hexes,
|
|
};
|
|
}
|
|
|
|
async function fetchGpsJamData() {
|
|
const now = Date.now();
|
|
if (cached && now - cachedAt < CACHE_TTL) return cached;
|
|
|
|
const redisData = await readFromRedis();
|
|
if (redisData && redisData.hexes?.length > 0) {
|
|
cached = redisData;
|
|
cachedAt = now;
|
|
return redisData;
|
|
}
|
|
|
|
if (now < negUntil) throw new Error('GPS interference data temporarily unavailable');
|
|
|
|
if (inflight) return inflight;
|
|
|
|
inflight = fetchDirectFromGpsJam()
|
|
.then((result) => {
|
|
cached = result;
|
|
cachedAt = Date.now();
|
|
inflight = null;
|
|
return result;
|
|
})
|
|
.catch((err) => {
|
|
inflight = null;
|
|
negUntil = Date.now() + NEG_TTL;
|
|
throw err;
|
|
});
|
|
|
|
return inflight;
|
|
}
|
|
|
|
export default async function handler(req) {
|
|
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
}
|
|
|
|
if (isDisallowedOrigin(req)) {
|
|
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const data = await fetchGpsJamData();
|
|
return new Response(JSON.stringify(data), {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 's-maxage=3600, stale-while-revalidate=1800, stale-if-error=3600',
|
|
...corsHeaders,
|
|
},
|
|
});
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: 'GPS interference data temporarily unavailable' }), {
|
|
status: 502,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
});
|
|
}
|
|
}
|