fix: call OpenSky API directly from browser to bypass Vercel IP blocks (#1)
* fix: switch flights API to edge runtime, lower timeouts
- Switch to Edge Runtime (30s timeout on ALL Vercel plans, near-zero cold starts)
- Lower FETCH_TIMEOUT_MS from 20s to 8s (prevents Vercel killing the function)
- Lower TOKEN_TIMEOUT_MS from 5s to 3s
- Replace Buffer.from() with btoa() for edge compatibility
- Remove maxDuration (not needed for edge functions)
Fixes 502 Bad Gateway on Vercel deployments caused by Hobby plan's
10s function timeout being exceeded by the 20s fetch timeout.
* fix: revert edge runtime, keep reduced timeouts
Edge runtime caused 500 on Vercel. Reverted to Node.js serverless
with reduced timeouts only:
- FETCH_TIMEOUT_MS: 20s -> 8s
- TOKEN_TIMEOUT_MS: 5s -> 3s
- Restored Buffer.from() for basic auth
* fix: handle DOMException unavailability in Vercel runtime
- Fix TypeError: 'Right-hand side of instanceof is not an object'
caused by DOMException not existing in Vercel's server runtime
- Use safe abort detection: check err.name === 'AbortError' on Error
first, then conditionally check DOMException if available
- Restore TOKEN_TIMEOUT_MS to 5s (3s was too aggressive from Vercel)
- Keep FETCH_TIMEOUT_MS at 8s (down from original 20s)
* fix: deploy to Frankfurt, increase timeouts, add retry logic
- Set preferredRegion to 'fra1' (Frankfurt) — closest to OpenSky EU servers
- Increase FETCH_TIMEOUT_MS to 15s and TOKEN_TIMEOUT_MS to 8s
(OpenSky is slow from cloud IPs, needs more time)
- Add retry logic: 1 retry with 500ms delay before giving up
- Keep safe DOMException handling for Vercel runtime compat
* feat: add Railway proxy for OpenSky API, proxy mode for Vercel route
OpenSky blocks/throttles requests from Vercel's IP ranges. This adds:
1. **Railway proxy** (`proxy/`) — standalone Node.js server that handles
OpenSky API calls with auth, caching, and CORS. Zero dependencies.
2. **Proxy mode in Vercel route** — when OPENSKY_PROXY_URL env var is set,
the Vercel /api/flights route forwards requests to the Railway proxy
instead of calling OpenSky directly.
Setup:
- Deploy proxy/ to Railway with OPENSKY credentials
- Set OPENSKY_PROXY_URL in Vercel to the Railway URL
- Remove OPENSKY credentials from Vercel (only needed on Railway)
* refactor: call OpenSky directly from browser, remove server-side proxy
OpenSky supports CORS (Access-Control-Allow-Origin: *), so we can
call the API directly from the user's browser. This bypasses
Vercel's cloud-provider IPs that OpenSky blocks.
Changes:
- opensky.ts: fetch from opensky-network.org/api directly (was /api/flights)
- use-flights.ts: fix DOMException abort detection
- Remove src/app/api/flights/route.ts (server-side proxy)
- Remove proxy/ directory (Railway proxy)
The app is now fully static — no server-side API routes needed.
* chore: remove server-side API route (now browser-side)
* chore: remove Railway proxy (no longer needed)
* chore: remove proxy package.json
* chore: remove proxy README
* polish: production-grade cleanup, security hardening, remove redundant comments
- Remove redundant JSDoc blocks in opensky.ts, keep only @see link
- Add bounds-clamping for lat/lng parameters (defense-in-depth)
- Fix memory leak: map.on("movestart") listener now cleaned up on unmount
- Validate GA_ID format before interpolating into script tag (XSS defense)
- Remove duplicate canonical <link> tag (already set via metadata.alternates)
- Sanitize JSON-LD output with \u003c escaping to prevent </script> injection
- Use useRef instead of useState for mutable TrailStore class instance
- Fix unused useState import in use-trail-history
- Add Map.displayName for React DevTools
- Fix Tailwind lint: px-[2px] → px-0.5
- Remove unused OPENSKY credentials from .env.example
* chore: push remaining polished files (flight-tracker, use-flights, map)
- flight-tracker: movestart listener cleanup on unmount
- use-flights: clean up redundant comments, fix abort detection
- map: add displayName, remove redundant comment prefix
* chore: polish control-panel (clean comments, Tailwind lint fix)
This commit is contained in:
19
.env.example
19
.env.example
@ -1,18 +1,9 @@
|
||||
# Environment Variables
|
||||
# Copy this file to .env.local and fill in your values.
|
||||
|
||||
# ─── OpenSky Network API ──────────────────────────────────────────────────────
|
||||
#
|
||||
# OPTION 1 (Recommended): OAuth2 Client Credentials
|
||||
# For accounts created since mid-March 2025.
|
||||
# Go to https://opensky-network.org → Account → Create API Client
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# OPTION 2: Basic Auth (Legacy accounts only)
|
||||
# Deprecated — will be removed. Only works for accounts created before March 2025.
|
||||
# OPENSKY_USERNAME=
|
||||
# OPENSKY_PASSWORD=
|
||||
|
||||
# ─── Analytics (optional) ─────────────────────────────────────────────────────
|
||||
# --- Analytics (optional) ---
|
||||
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||
|
||||
# --- Notes ---
|
||||
# OpenSky API calls are made directly from the browser (CORS supported).
|
||||
# No server-side credentials are needed.
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
const OPENSKY_BASE = "https://opensky-network.org/api";
|
||||
const OPENSKY_TOKEN_URL =
|
||||
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
|
||||
const TOKEN_TIMEOUT_MS = 5_000;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
const CACHE_TTL_MS = 25_000;
|
||||
const MAX_REQUESTS_PER_MINUTE = 20;
|
||||
const MAX_BBOX_SPAN = 20;
|
||||
const CACHE_GRID_STEP = 0.5;
|
||||
|
||||
let cachedToken: string | null = null;
|
||||
let tokenExpiresAt = 0;
|
||||
|
||||
async function getAccessToken(): Promise<string | null> {
|
||||
const clientId = process.env.OPENSKY_CLIENT_ID;
|
||||
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS);
|
||||
const res = await fetch(OPENSKY_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`[aeris] Token request failed: ${res.status}`);
|
||||
cachedToken = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = data.access_token;
|
||||
tokenExpiresAt = Date.now() + (data.expires_in ?? 1800) * 1000;
|
||||
return cachedToken;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[aeris] Token error:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
cachedToken = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type AuthMode = "oauth2" | "basic" | "anonymous";
|
||||
let authDisabled = false;
|
||||
let authLoggedOnce = false;
|
||||
|
||||
function detectAuthMode(): AuthMode {
|
||||
if (authDisabled) return "anonymous";
|
||||
if (process.env.OPENSKY_CLIENT_ID && process.env.OPENSKY_CLIENT_SECRET)
|
||||
return "oauth2";
|
||||
if (process.env.OPENSKY_USERNAME && process.env.OPENSKY_PASSWORD)
|
||||
return "basic";
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
async function buildAuthHeaders(): Promise<HeadersInit> {
|
||||
const mode = detectAuthMode();
|
||||
|
||||
if (mode === "oauth2") {
|
||||
const token = await getAccessToken();
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
if (mode === "basic") {
|
||||
const user = process.env.OPENSKY_USERNAME!;
|
||||
const pass = process.env.OPENSKY_PASSWORD!;
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function logAuthOnce() {
|
||||
if (authLoggedOnce) return;
|
||||
authLoggedOnce = true;
|
||||
console.info(`[aeris] Auth mode: ${detectAuthMode()}`);
|
||||
}
|
||||
|
||||
const requestLog = new Map<string, number[]>();
|
||||
|
||||
function isRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const window = 60_000;
|
||||
const timestamps = requestLog.get(ip) ?? [];
|
||||
const recent = timestamps.filter((t) => now - t < window);
|
||||
recent.push(now);
|
||||
requestLog.set(ip, recent);
|
||||
|
||||
if (requestLog.size > 500) {
|
||||
for (const [key, val] of requestLog) {
|
||||
if (val.every((t) => now - t > window)) requestLog.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return recent.length > MAX_REQUESTS_PER_MINUTE;
|
||||
}
|
||||
|
||||
let responseCache: {
|
||||
key: string;
|
||||
data: unknown;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
function getCached(key: string): unknown | null {
|
||||
if (
|
||||
responseCache &&
|
||||
responseCache.key === key &&
|
||||
Date.now() < responseCache.expiresAt
|
||||
) {
|
||||
return responseCache.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCache(key: string, data: unknown): void {
|
||||
responseCache = { key, data, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
}
|
||||
|
||||
async function fetchOpenSky(
|
||||
url: string,
|
||||
useAuth: boolean,
|
||||
): Promise<Response> {
|
||||
const headers = useAuth ? await buildAuthHeaders() : {};
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(val: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, val));
|
||||
}
|
||||
|
||||
function json(
|
||||
body: unknown,
|
||||
status: number,
|
||||
extra?: Record<string, string>,
|
||||
) {
|
||||
return NextResponse.json(body, {
|
||||
status,
|
||||
headers: { "Cache-Control": "no-store", ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
if (isRateLimited(ip)) {
|
||||
return json({ time: 0, states: null, rateLimited: true }, 200);
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const lamin = searchParams.get("lamin");
|
||||
const lamax = searchParams.get("lamax");
|
||||
const lomin = searchParams.get("lomin");
|
||||
const lomax = searchParams.get("lomax");
|
||||
|
||||
if (!lamin || !lamax || !lomin || !lomax) {
|
||||
return json({ error: "Missing required bbox parameters" }, 400);
|
||||
}
|
||||
|
||||
const raw = { lamin: +lamin, lamax: +lamax, lomin: +lomin, lomax: +lomax };
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (Number.isNaN(val)) {
|
||||
return json({ error: `Invalid parameter: ${key}` }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const coords = {
|
||||
lamin: clamp(raw.lamin, -90, 90),
|
||||
lamax: clamp(raw.lamax, -90, 90),
|
||||
lomin: clamp(raw.lomin, -180, 180),
|
||||
lomax: clamp(raw.lomax, -180, 180),
|
||||
};
|
||||
|
||||
if (
|
||||
Math.abs(coords.lamax - coords.lamin) > MAX_BBOX_SPAN ||
|
||||
Math.abs(coords.lomax - coords.lomin) > MAX_BBOX_SPAN
|
||||
) {
|
||||
return json(
|
||||
{ error: `Bounding box too large (max ${MAX_BBOX_SPAN}° per axis)` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
logAuthOnce();
|
||||
|
||||
// Snap bbox to grid so nearby viewports share cache entries
|
||||
const snap = (v: number) =>
|
||||
Math.round(v / CACHE_GRID_STEP) * CACHE_GRID_STEP;
|
||||
const snapped = {
|
||||
lamin: snap(coords.lamin),
|
||||
lamax: snap(coords.lamax),
|
||||
lomin: snap(coords.lomin),
|
||||
lomax: snap(coords.lomax),
|
||||
};
|
||||
|
||||
const url = `${OPENSKY_BASE}/states/all?lamin=${snapped.lamin}&lamax=${snapped.lamax}&lomin=${snapped.lomin}&lomax=${snapped.lomax}`;
|
||||
const cacheKey = `${snapped.lamin},${snapped.lamax},${snapped.lomin},${snapped.lomax}`;
|
||||
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) {
|
||||
return json(cached, 200, { "X-Cache": "HIT" });
|
||||
}
|
||||
|
||||
const useAuth = detectAuthMode() !== "anonymous";
|
||||
|
||||
try {
|
||||
let res = await fetchOpenSky(url, useAuth);
|
||||
|
||||
if (res.status === 401 && useAuth) {
|
||||
cachedToken = null;
|
||||
tokenExpiresAt = 0;
|
||||
authDisabled = true;
|
||||
console.warn("[aeris] Auth rejected (401), falling back to anonymous");
|
||||
res = await fetchOpenSky(url, false);
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfter = res.headers.get(
|
||||
"X-Rate-Limit-Retry-After-Seconds",
|
||||
);
|
||||
return json(
|
||||
{
|
||||
time: 0,
|
||||
states: null,
|
||||
rateLimited: true,
|
||||
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
|
||||
},
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
console.error(`[aeris] OpenSky ${res.status}: ${body.slice(0, 300)}`);
|
||||
return json(
|
||||
{ error: "Upstream data source error", status: res.status },
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const creditsRaw = res.headers.get("X-Rate-Limit-Remaining");
|
||||
const creditsRemaining =
|
||||
creditsRaw !== null ? parseInt(creditsRaw, 10) : null;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
console.error("[aeris] OpenSky returned non-JSON response");
|
||||
return json({ error: "Upstream returned invalid response" }, 502);
|
||||
}
|
||||
|
||||
if (creditsRemaining !== null && !Number.isNaN(creditsRemaining)) {
|
||||
data.creditsRemaining = creditsRemaining;
|
||||
}
|
||||
|
||||
setCache(cacheKey, data);
|
||||
return json(data, 200, { "X-Cache": "MISS" });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
console.error(`[aeris] OpenSky timed out (${FETCH_TIMEOUT_MS}ms)`);
|
||||
return json(
|
||||
{ error: "Upstream request timed out", timeout: true },
|
||||
504,
|
||||
);
|
||||
}
|
||||
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[aeris] Proxy error: ${msg}`);
|
||||
return json(
|
||||
{ error: "Failed to fetch flight data", detail: msg },
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -61,8 +61,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<link rel="canonical" href={siteUrl} />
|
||||
{GA_ID && (
|
||||
{GA_ID && /^G-[A-Z0-9]+$/.test(GA_ID) && (
|
||||
<>
|
||||
<Script
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
|
||||
|
||||
@ -18,7 +18,9 @@ export default function Home() {
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<FlightTracker />
|
||||
</>
|
||||
|
||||
@ -150,9 +150,10 @@ function CameraController({ city }: { city: City }) {
|
||||
container.addEventListener(e, resetIdleTimer, { passive: true }),
|
||||
);
|
||||
|
||||
map.on("movestart", () => {
|
||||
const onMoveStart = () => {
|
||||
if (isInteractingRef.current) stopOrbit();
|
||||
});
|
||||
};
|
||||
map.on("movestart", onMoveStart);
|
||||
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
isInteractingRef.current = false;
|
||||
@ -163,6 +164,7 @@ function CameraController({ city }: { city: City }) {
|
||||
stopOrbit();
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
|
||||
map.off("movestart", onMoveStart);
|
||||
};
|
||||
}, [
|
||||
map,
|
||||
|
||||
@ -96,7 +96,6 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
if (!mapInstance || !isLoaded) return;
|
||||
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
|
||||
|
||||
// Re-apply terrain/sky after style load (MapLibre can drop these on setStyle)
|
||||
const applyTerrain = () => {
|
||||
if (typeof mapStyle === "object" && "terrain" in mapStyle) {
|
||||
const spec = mapStyle as Record<string, unknown>;
|
||||
@ -138,3 +137,5 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
</MapContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Map.displayName = "Map";
|
||||
|
||||
@ -236,7 +236,7 @@ function PanelDialog({
|
||||
</a>
|
||||
<div className="border-t border-white/3 pt-2 px-2.5">
|
||||
<p className="text-[10px] font-medium text-white/10 tracking-wide">
|
||||
v0.1 · OpenSky Network
|
||||
v0.1 \u00b7 OpenSky Network
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -298,7 +298,7 @@ function PanelDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile tab bar — at bottom for thumb reach */}
|
||||
{/* Mobile tab bar */}
|
||||
<div className="flex sm:hidden items-center gap-1 border-t border-white/6 px-3 pt-2 pb-3">
|
||||
<nav className="flex flex-1 gap-1">
|
||||
{TABS.map(({ id, icon: Icon, label }) => {
|
||||
@ -423,7 +423,7 @@ function SearchContent({
|
||||
{city.name}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-white/25">
|
||||
{city.iata} · {city.country}
|
||||
{city.iata} \u00b7 {city.country}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" />
|
||||
@ -457,7 +457,7 @@ function StyleContent({
|
||||
</div>
|
||||
<div className="border-t border-white/4 px-5 py-3">
|
||||
<p className="text-[11px] font-medium text-white/12">
|
||||
Satellite © Esri · Terrain © OpenTopoMap · Base maps ©
|
||||
Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base maps \u00a9
|
||||
CARTO
|
||||
</p>
|
||||
</div>
|
||||
@ -632,7 +632,7 @@ function OrbitSpeedSlider({
|
||||
const activeLabel =
|
||||
ORBIT_SPEED_PRESETS.find(
|
||||
(p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD,
|
||||
)?.label ?? `${value.toFixed(2)}×`;
|
||||
)?.label ?? `${value.toFixed(2)}\u00d7`;
|
||||
|
||||
function handleChange(vals: number[]) {
|
||||
let raw = vals[0];
|
||||
@ -666,7 +666,7 @@ function OrbitSpeedSlider({
|
||||
onValueChange={handleChange}
|
||||
aria-label="Orbit speed"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-[2px]">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-0.5">
|
||||
{ORBIT_SPEED_PRESETS.map((preset) => {
|
||||
const pct =
|
||||
((preset.value - ORBIT_SPEED_MIN) /
|
||||
|
||||
@ -13,17 +13,15 @@ const CONSERVATIVE_POLL_MS = 60_000;
|
||||
const CAUTIOUS_POLL_MS = 120_000;
|
||||
const EMERGENCY_POLL_MS = 300_000;
|
||||
|
||||
// Credit thresholds (out of 4 000 daily for authenticated users)
|
||||
const CREDIT_TIER_CONSERVATIVE = 2_000; // < 50 % remaining
|
||||
const CREDIT_TIER_CAUTIOUS = 800; // < 20 %
|
||||
const CREDIT_TIER_EMERGENCY = 200; // < 5 %
|
||||
const CREDIT_TIER_CONSERVATIVE = 2_000;
|
||||
const CREDIT_TIER_CAUTIOUS = 800;
|
||||
const CREDIT_TIER_EMERGENCY = 200;
|
||||
|
||||
const RATE_LIMIT_BACKOFF_MS = 30_000;
|
||||
const VISIBILITY_RESUME_STALE_MS = 60_000;
|
||||
|
||||
/** Choose a poll interval based on how many API credits remain today. */
|
||||
function adaptiveInterval(creditsRemaining: number | null): number {
|
||||
if (creditsRemaining === null) return BASE_POLL_MS; // unknown → default
|
||||
if (creditsRemaining === null) return BASE_POLL_MS;
|
||||
if (creditsRemaining < CREDIT_TIER_EMERGENCY) return EMERGENCY_POLL_MS;
|
||||
if (creditsRemaining < CREDIT_TIER_CAUTIOUS) return CAUTIOUS_POLL_MS;
|
||||
if (creditsRemaining < CREDIT_TIER_CONSERVATIVE) return CONSERVATIVE_POLL_MS;
|
||||
@ -118,10 +116,10 @@ export function useFlights(city: City | null) {
|
||||
const nextInterval = adaptiveInterval(creditsRef.current);
|
||||
scheduleNext(target, nextInterval);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
const isAbort = err instanceof Error && err.name === "AbortError";
|
||||
if (isAbort) return;
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setFlights([]);
|
||||
// After an error, back off longer to avoid hammering a sick upstream
|
||||
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -137,22 +135,18 @@ export function useFlights(city: City | null) {
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Tab just became visible — decide whether to fetch now or schedule
|
||||
const elapsed = Date.now() - lastFetchRef.current;
|
||||
|
||||
if (elapsed >= VISIBILITY_RESUME_STALE_MS) {
|
||||
// Data is stale after being hidden for a while; fetch immediately
|
||||
clearSchedule();
|
||||
fetchData(activeCity);
|
||||
} else {
|
||||
// Data is still fresh — schedule for the remaining time
|
||||
const interval = adaptiveInterval(creditsRef.current);
|
||||
const remaining = Math.max(1_000, interval - elapsed);
|
||||
clearSchedule();
|
||||
scheduleNext(activeCity, remaining);
|
||||
}
|
||||
} else {
|
||||
// Tab hidden — cancel scheduled poll to save credits
|
||||
clearSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRef, useMemo } from "react";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
|
||||
type Position = [lng: number, lat: number];
|
||||
@ -32,7 +32,6 @@ function catmullRomSmooth(
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(points.length - 1, i + 2)];
|
||||
|
||||
// Knot intervals
|
||||
const d01 = Math.pow(Math.hypot(p1[0] - p0[0], p1[1] - p0[1]), 0.5) || 1e-6;
|
||||
const d12 = Math.pow(Math.hypot(p2[0] - p1[0], p2[1] - p1[1]), 0.5) || 1e-6;
|
||||
const d23 = Math.pow(Math.hypot(p3[0] - p2[0], p3[1] - p2[1]), 0.5) || 1e-6;
|
||||
@ -45,7 +44,6 @@ function catmullRomSmooth(
|
||||
for (let s = 1; s <= samplesPerSegment; s++) {
|
||||
const t = t1 + (t2 - t1) * (s / samplesPerSegment);
|
||||
|
||||
// Barry-Goldman interpolation
|
||||
const a1x =
|
||||
((t1 - t) / (t1 - t0)) * p0[0] + ((t - t0) / (t1 - t0)) * p1[0];
|
||||
const a1y =
|
||||
@ -143,6 +141,7 @@ class TrailStore {
|
||||
}
|
||||
|
||||
export function useTrailHistory(flights: FlightState[]): TrailEntry[] {
|
||||
const [store] = useState(() => new TrailStore());
|
||||
return useMemo(() => store.update(flights), [store, flights]);
|
||||
const storeRef = useRef<TrailStore>(null);
|
||||
if (!storeRef.current) storeRef.current = new TrailStore();
|
||||
return useMemo(() => storeRef.current!.update(flights), [flights]);
|
||||
}
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
/** @see https://openskynetwork.github.io/opensky-api/rest.html */
|
||||
|
||||
const OPENSKY_API = "https://opensky-network.org/api";
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type FlightState = {
|
||||
icao24: string;
|
||||
callsign: string | null;
|
||||
@ -15,11 +20,9 @@ export type FlightState = {
|
||||
positionSource: number;
|
||||
};
|
||||
|
||||
export type OpenSkyResponse = {
|
||||
type OpenSkyResponse = {
|
||||
time: number;
|
||||
states: (string | number | boolean | null)[][] | null;
|
||||
rateLimited?: boolean;
|
||||
creditsRemaining?: number | null;
|
||||
};
|
||||
|
||||
function parseStates(raw: OpenSkyResponse): FlightState[] {
|
||||
@ -57,7 +60,8 @@ export type FetchResult = {
|
||||
creditsRemaining: number | null;
|
||||
};
|
||||
|
||||
/** Fetch flights via the server-side proxy. */
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
|
||||
|
||||
export async function fetchFlightsByBbox(
|
||||
lamin: number,
|
||||
lamax: number,
|
||||
@ -65,29 +69,58 @@ export async function fetchFlightsByBbox(
|
||||
lomax: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FetchResult> {
|
||||
const url = `/api/flights?lamin=${lamin}&lamax=${lamax}&lomin=${lomin}&lomax=${lomax}`;
|
||||
const la0 = clamp(lamin, -90, 90);
|
||||
const la1 = clamp(lamax, -90, 90);
|
||||
const lo0 = clamp(lomin, -180, 180);
|
||||
const lo1 = clamp(lomax, -180, 180);
|
||||
|
||||
const res = await fetch(url, { cache: "no-store", signal });
|
||||
const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}`;
|
||||
|
||||
if (!res.ok) {
|
||||
// Don't throw — let the hook retry gracefully
|
||||
console.warn(`[aeris] Flight API returned ${res.status}`);
|
||||
return { flights: [], rateLimited: false, creditsRemaining: null };
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const onExternalAbort = () => controller.abort();
|
||||
signal?.addEventListener("abort", onExternalAbort);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
console.warn("[aeris] OpenSky rate limit hit (429), backing off");
|
||||
return { flights: [], rateLimited: true, creditsRemaining: null };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[aeris] OpenSky returned ${res.status}`);
|
||||
return { flights: [], rateLimited: false, creditsRemaining: null };
|
||||
}
|
||||
|
||||
const data: OpenSkyResponse = await res.json();
|
||||
|
||||
const creditsRaw = res.headers.get("x-rate-limit-remaining");
|
||||
const creditsRemaining =
|
||||
creditsRaw !== null ? parseInt(creditsRaw, 10) : null;
|
||||
|
||||
const flights = parseStates(data);
|
||||
return {
|
||||
flights,
|
||||
rateLimited: false,
|
||||
creditsRemaining: Number.isNaN(creditsRemaining)
|
||||
? null
|
||||
: creditsRemaining,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
if (signal?.aborted) throw err;
|
||||
throw new Error("OpenSky request timed out");
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onExternalAbort);
|
||||
}
|
||||
|
||||
const data: OpenSkyResponse = await res.json();
|
||||
|
||||
if (data.rateLimited) {
|
||||
console.warn("[aeris] OpenSky rate limit hit, backing off");
|
||||
return { flights: [], rateLimited: true, creditsRemaining: null };
|
||||
}
|
||||
|
||||
const flights = parseStates(data);
|
||||
return {
|
||||
flights,
|
||||
rateLimited: false,
|
||||
creditsRemaining: data.creditsRemaining ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function bboxFromCenter(
|
||||
|
||||
Reference in New Issue
Block a user