* Refactor aircraft photo and hero banner components to reset loading state on photo change - Updated Lightbox component to reset image loading state when navigating between photos. - Modified HeroBanner component to reset loading state when the photo changes. Clean up control panel search logic - Removed unnecessary hasResults variable in SearchContent component. Implement flight API client with fallback mechanism - Added flight-api-client to handle fetching flight data from multiple sources (airplanes.live, adsb.lol, OpenSky). - Introduced flight-api-parsing module to convert raw API responses into standardized FlightState objects. - Created flight-api-types for shared types between API responses. Refactor useFlights hook to utilize new flight API client - Updated useFlights hook to fetch flights using the new flight API client. - Removed credit management logic as it is no longer applicable with the new API structure. Fix useFlightMonitors to fetch flight data by hex address - Changed useFlightMonitors to use fetchFlightByHex instead of fetchFlightByIcao24. Update geo utility function for better readability - Refactored splitAtAntimeridian function to improve variable naming and clarity. Enhance OpenSky types with additional fields - Added typeCode and registration fields to FlightState type for better integration with readsb data. * fix: correct 6 files that diverged during rebase (iata code, globe mode ref, terrain attribution, cache eviction, opensky parsing) * fix: improve keyboard shortcuts help focus trapping feat: add showAirspace option to MapAttribution component fix: clear hideTimer on ScrollArea cleanup refactor: change pendingFpvRef to MutableRefObject in useFlightMonitors fix: handle sessionStorage availability in useFlightTrack refactor: increase POLL_INTERVAL_MS in useFlights for better performance fix: optimize keyboard shortcuts dialog check refactor: optimize useMergedTrails by caching selected flight position feat: extend Settings type with airspace options refactor: improve airline logo normalization functions refactor: enhance flight API client with serialized rate limiting refactor: optimize registration country lookup with pre-built maps refactor: enhance logo cache management with size limits feat: update map attribution to include airspace option fix: validate rawState in parseStateRow function refactor: improve utility functions with clamp implementation * feat: add ATC lookup functionality and GPU memory monitoring - Implemented ATC lookup functions in `atc-lookup.ts` for converting IATA to ICAO codes, finding nearby ATC feeds, and looking up ATC feeds by code. - Introduced `atc-types.ts` to define types and priorities for ATC feeds. - Added GPU memory monitoring in `gpu-memory-monitor.ts` to track WebGL resource allocations and provide memory reports. - Enhanced trail stitching logic in `trail-stitching.ts` by adding a function to clear the splined track cache and optimizing altitude checks. * feat: enhance flight data handling and improve API resilience - Implemented a maximum empty response streak guard in useFlights to prevent data loss during transient API failures. - Added immediate fetch on network reconnect in useFlights to ensure timely data retrieval. - Updated useMergedTrails to include timestamps for trail points. - Removed smoothAnimations setting from useSettings as it is no longer needed. - Enhanced useTrailHistory to preserve last-known trails during empty flight responses and added dynamic jump detection for tab resume scenarios. - Improved flight API client with a circuit breaker mechanism to handle provider failures and prevent excessive retries. - Updated flight API parsing to reject non-JSON responses from OpenSky and other providers. - Enhanced trail smoothing and stitching logic to ensure better continuity at junctions between historical and live data. * feat: migrate aircraft models to Cloudinary CDN and update mapping logic * fix: adjust UI component styles and improve trail smoothing parameters * fix: adjust base aircraft size for improved rendering * feat: update changelog with recent enhancements and modify data source attribution * fix: update model optimization details and remove Draco compression dependency * feat: update changelog with recent code review fixes and fallback provider adjustments
This commit is contained in:
498
src/lib/flight-api-client.ts
Normal file
498
src/lib/flight-api-client.ts
Normal file
@ -0,0 +1,498 @@
|
||||
// ── readsb API Client ────────────────────────────────────────────────
|
||||
//
|
||||
// 2-tier fallback: adsb.lol proxy → OpenSky.
|
||||
// Dev/override: ?provider=airplanes|adsb|opensky in the URL.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { FlightState } from "./opensky-types";
|
||||
import type { ReadsbApiResponse } from "./flight-api-types";
|
||||
import { MAX_RADIUS_NM, NM_PER_DEG_LAT } from "./flight-api-types";
|
||||
import { parseAircraftList, type ParseOptions } from "./flight-api-parsing";
|
||||
import {
|
||||
bboxFromCenter,
|
||||
fetchFlightsByBbox,
|
||||
fetchFlightByIcao24 as openskyFetchByIcao24,
|
||||
} from "./opensky-flights";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProviderName = "airplanes" | "adsb" | "opensky" | "auto";
|
||||
|
||||
export interface FlightApiFetchResult {
|
||||
flights: FlightState[];
|
||||
rateLimited: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ── Circuit Breaker ────────────────────────────────────────────────────
|
||||
//
|
||||
// Prevents hammering a dead provider. After 3 consecutive non-abort,
|
||||
// non-rate-limit failures the circuit OPENS — the tier is skipped for a
|
||||
// cooldown window. After the window elapses the state transitions to
|
||||
// HALF-OPEN and a single probe request is allowed through:
|
||||
// • probe succeeds → CLOSED (reset)
|
||||
// • probe fails → OPEN (cooldown doubles, capped at 120 s)
|
||||
//
|
||||
// What counts as a failure:
|
||||
// ✓ Timeout, HTTP 5xx, non-JSON response, network error
|
||||
// ✗ AbortError (tab switch / navigation)
|
||||
// ✗ 429 rate-limit (server is alive, handled separately)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CircuitState = "closed" | "open" | "half-open";
|
||||
|
||||
interface TierCircuit {
|
||||
state: CircuitState;
|
||||
failures: number;
|
||||
/** Timestamp after which OPEN → HALF-OPEN */
|
||||
openUntil: number;
|
||||
}
|
||||
|
||||
const CIRCUIT_FAILURE_THRESHOLD = 3;
|
||||
const CIRCUIT_BASE_COOLDOWN_MS = 30_000; // 30 s
|
||||
const CIRCUIT_MAX_COOLDOWN_MS = 120_000; // 2 min
|
||||
|
||||
const circuits = new Map<string, TierCircuit>();
|
||||
|
||||
function shouldSkipTier(tierId: string): boolean {
|
||||
const c = circuits.get(tierId);
|
||||
if (!c || c.state === "closed") return false;
|
||||
if (c.state === "open" && Date.now() >= c.openUntil) {
|
||||
// Cooldown expired — allow one probe
|
||||
c.state = "half-open";
|
||||
return false;
|
||||
}
|
||||
return c.state === "open";
|
||||
}
|
||||
|
||||
function recordSuccess(tierId: string): void {
|
||||
circuits.set(tierId, { state: "closed", failures: 0, openUntil: 0 });
|
||||
}
|
||||
|
||||
function recordFailure(tierId: string): void {
|
||||
const c = circuits.get(tierId) ?? {
|
||||
state: "closed" as CircuitState,
|
||||
failures: 0,
|
||||
openUntil: 0,
|
||||
};
|
||||
c.failures++;
|
||||
if (c.failures >= CIRCUIT_FAILURE_THRESHOLD) {
|
||||
// Cooldown: 30s → 60s → 120s → 120s …
|
||||
const exponent = c.failures - CIRCUIT_FAILURE_THRESHOLD;
|
||||
const cooldown = Math.min(
|
||||
CIRCUIT_BASE_COOLDOWN_MS * Math.pow(2, exponent),
|
||||
CIRCUIT_MAX_COOLDOWN_MS,
|
||||
);
|
||||
c.state = "open";
|
||||
c.openUntil = Date.now() + cooldown;
|
||||
}
|
||||
circuits.set(tierId, c);
|
||||
}
|
||||
|
||||
/** Returns true if this error should NOT trip the circuit breaker. */
|
||||
function isNonCircuitError(err: unknown): boolean {
|
||||
// Abort = tab switch / navigation — not a provider failure
|
||||
if (err instanceof DOMException && err.name === "AbortError") return true;
|
||||
// 429 = server is alive, just rate-limiting — already handled via rateLimited flag
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message.toLowerCase()
|
||||
: String(err).toLowerCase();
|
||||
if (msg.includes("429") || msg.includes("rate limit")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Circuit State API (for UI consumption) ─────────────────────────────
|
||||
|
||||
/** Read the circuit breaker state for a specific tier. */
|
||||
export function getCircuitState(tierId: string): {
|
||||
state: CircuitState;
|
||||
failures: number;
|
||||
cooldownRemaining: number;
|
||||
} {
|
||||
const c = circuits.get(tierId);
|
||||
if (!c || c.state === "closed")
|
||||
return { state: "closed", failures: 0, cooldownRemaining: 0 };
|
||||
return {
|
||||
state: c.state,
|
||||
failures: c.failures,
|
||||
cooldownRemaining: Math.max(0, c.openUntil - Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset all circuits (e.g. on network reconnect). */
|
||||
export function resetAllCircuits(): void {
|
||||
circuits.clear();
|
||||
}
|
||||
|
||||
let _onlineListenerRegistered = false;
|
||||
if (typeof window !== "undefined" && !_onlineListenerRegistered) {
|
||||
_onlineListenerRegistered = true;
|
||||
window.addEventListener("online", resetAllCircuits);
|
||||
}
|
||||
|
||||
// ── Provider Override (dev testing) ────────────────────────────────────
|
||||
|
||||
export function getProviderOverride(): ProviderName {
|
||||
if (typeof window === "undefined") return "auto";
|
||||
const p = new URLSearchParams(window.location.search)
|
||||
.get("provider")
|
||||
?.toLowerCase();
|
||||
if (p === "airplanes" || p === "adsb" || p === "opensky") return p;
|
||||
return "auto";
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const AIRPLANES_LIVE_BASE = "https://api.airplanes.live/v2";
|
||||
const DIRECT_TIMEOUT_MS = 10_000;
|
||||
const PROXY_TIMEOUT_MS = 15_000;
|
||||
|
||||
// Client-side rate limiter for direct airplanes.live (1 req/s + margin).
|
||||
// Uses a Promise chain to serialize slot acquisition — concurrent callers
|
||||
// queue up instead of both reading the same timestamp and firing together.
|
||||
const DIRECT_RATE_MS = 1_100;
|
||||
let lastDirectTime = 0;
|
||||
let rateQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function acquireDirectSlot(): Promise<void> {
|
||||
const slot = rateQueue.then(async () => {
|
||||
const elapsed = Date.now() - lastDirectTime;
|
||||
const wait = Math.max(0, DIRECT_RATE_MS - elapsed);
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
lastDirectTime = Date.now();
|
||||
});
|
||||
rateQueue = slot;
|
||||
await slot;
|
||||
}
|
||||
|
||||
// ── Internal Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function degreesToNm(degrees: number): number {
|
||||
if (!Number.isFinite(degrees) || degrees <= 0) return 150;
|
||||
const nm = Math.round(degrees * NM_PER_DEG_LAT);
|
||||
return Math.min(Math.max(nm, 1), MAX_RADIUS_NM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` with a timeout. External abort signals are propagated.
|
||||
*/
|
||||
async function withTimeout<T>(
|
||||
fn: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (externalSignal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
externalSignal?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
return await fn(controller.signal);
|
||||
} catch (err) {
|
||||
// If the external signal fired, surface as AbortError
|
||||
if (externalSignal?.aborted)
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
externalSignal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function validateReadsb(payload: unknown): ReadsbApiResponse {
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== "object" ||
|
||||
!Array.isArray((payload as ReadsbApiResponse).ac)
|
||||
) {
|
||||
throw new Error("Invalid readsb response shape");
|
||||
}
|
||||
return payload as ReadsbApiResponse;
|
||||
}
|
||||
|
||||
// ── Tier 1: Direct to airplanes.live ───────────────────────────────────
|
||||
//
|
||||
// Avoid headers that trigger CORS preflight (Cache-Control, Pragma, etc.)
|
||||
// since airplanes.live returns 405 for OPTIONS. Use cache-busting query
|
||||
// param instead of cache: "no-store".
|
||||
|
||||
async function fetchDirectAirplanesLive(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadsbApiResponse> {
|
||||
// Serialized rate limiting — concurrent callers queue up
|
||||
await acquireDirectSlot();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
return withTimeout(
|
||||
async (innerSignal) => {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const url = `${AIRPLANES_LIVE_BASE}${path}${sep}_t=${Date.now()}`;
|
||||
|
||||
const res = await fetch(url, { signal: innerSignal });
|
||||
if (!res.ok) throw new Error(`airplanes.live ${res.status}`);
|
||||
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
throw new Error("airplanes.live returned non-JSON response");
|
||||
}
|
||||
|
||||
return validateReadsb(await res.json());
|
||||
},
|
||||
DIRECT_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tier 2: adsb.lol via server proxy ──────────────────────────────────
|
||||
|
||||
async function fetchViaProxy(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadsbApiResponse> {
|
||||
return withTimeout(
|
||||
async (innerSignal) => {
|
||||
const url = `/api/flights?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, { cache: "no-store", signal: innerSignal });
|
||||
|
||||
if (!res.ok) throw new Error(`adsb.lol proxy ${res.status}`);
|
||||
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
throw new Error("adsb.lol proxy returned non-JSON response");
|
||||
}
|
||||
|
||||
return validateReadsb(await res.json());
|
||||
},
|
||||
PROXY_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tier 3: OpenSky direct ─────────────────────────────────────────────
|
||||
|
||||
async function fetchFromOpenSkyPoint(
|
||||
lat: number,
|
||||
lon: number,
|
||||
radiusDeg: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FlightState[]> {
|
||||
const [lamin, lamax, lomin, lomax] = bboxFromCenter(lon, lat, radiusDeg);
|
||||
const result = await fetchFlightsByBbox(lamin, lamax, lomin, lomax, signal);
|
||||
if (result.rateLimited) throw new Error("OpenSky rate limited (429)");
|
||||
return result.flights;
|
||||
}
|
||||
|
||||
// ── Fallback Engine ────────────────────────────────────────────────────
|
||||
|
||||
interface NamedTier {
|
||||
id: string;
|
||||
fn: () => Promise<FlightState[]>;
|
||||
}
|
||||
|
||||
async function runFallbackChain(
|
||||
tiers: NamedTier[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<FlightApiFetchResult> {
|
||||
let lastError: Error | null = null;
|
||||
let allSkipped = true;
|
||||
let lastTriedId: string | undefined;
|
||||
|
||||
for (const { id, fn } of tiers) {
|
||||
if (shouldSkipTier(id)) continue;
|
||||
allSkipped = false;
|
||||
lastTriedId = id;
|
||||
|
||||
try {
|
||||
const flights = await fn();
|
||||
recordSuccess(id);
|
||||
return { flights, rateLimited: false, source: id };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
|
||||
if (!isNonCircuitError(err)) recordFailure(id);
|
||||
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (allSkipped) {
|
||||
return { flights: [], rateLimited: false, source: "none" };
|
||||
}
|
||||
|
||||
const msg = lastError?.message?.toLowerCase() ?? "";
|
||||
if (msg.includes("429") || msg.includes("rate limit")) {
|
||||
return { flights: [], rateLimited: true, source: lastTriedId };
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("All flight providers failed");
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch flights within a radius of a geographic point.
|
||||
* Uses the fallback chain: adsb.lol → OpenSky.
|
||||
*/
|
||||
export async function fetchFlightsByPoint(
|
||||
lat: number,
|
||||
lon: number,
|
||||
radiusDeg: number,
|
||||
signal?: AbortSignal,
|
||||
options?: ParseOptions,
|
||||
): Promise<FlightApiFetchResult> {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return { flights: [], rateLimited: false };
|
||||
}
|
||||
|
||||
const radiusNm = degreesToNm(radiusDeg);
|
||||
const cLat = Math.max(-90, Math.min(90, lat));
|
||||
const cLon = Math.max(-180, Math.min(180, lon));
|
||||
const readsbPath = `/point/${cLat.toFixed(4)}/${cLon.toFixed(4)}/${radiusNm}`;
|
||||
|
||||
const override = getProviderOverride();
|
||||
const tiers: NamedTier[] = [];
|
||||
|
||||
// Skip direct airplanes.live in the browser — CORS blocks it.
|
||||
// Only attempt when explicitly overridden via ?provider=airplanes.
|
||||
if (override === "airplanes") {
|
||||
tiers.push({
|
||||
id: "airplanes",
|
||||
fn: async () => {
|
||||
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, options);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "adsb") {
|
||||
tiers.push({
|
||||
id: "adsb",
|
||||
fn: async () => {
|
||||
const resp = await fetchViaProxy(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, options);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "opensky") {
|
||||
tiers.push({
|
||||
id: "opensky",
|
||||
fn: () => fetchFromOpenSkyPoint(cLat, cLon, radiusDeg, signal),
|
||||
});
|
||||
}
|
||||
|
||||
return runFallbackChain(tiers, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single aircraft by ICAO24 hex address.
|
||||
* Uses the fallback chain: adsb.lol → OpenSky.
|
||||
*/
|
||||
export async function fetchFlightByHex(
|
||||
icao24: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ flight: FlightState | null }> {
|
||||
const normalized = icao24.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{6}$/i.test(normalized)) {
|
||||
return { flight: null };
|
||||
}
|
||||
|
||||
const parseOpts: ParseOptions = {
|
||||
includeGround: true,
|
||||
requireBaroAltitude: false,
|
||||
};
|
||||
const readsbPath = `/hex/${encodeURIComponent(normalized)}`;
|
||||
const override = getProviderOverride();
|
||||
const tiers: NamedTier[] = [];
|
||||
|
||||
if (override === "airplanes") {
|
||||
tiers.push({
|
||||
id: "airplanes",
|
||||
fn: async () => {
|
||||
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "adsb") {
|
||||
tiers.push({
|
||||
id: "adsb",
|
||||
fn: async () => {
|
||||
const resp = await fetchViaProxy(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "opensky") {
|
||||
tiers.push({
|
||||
id: "opensky",
|
||||
fn: async () => {
|
||||
const result = await openskyFetchByIcao24(normalized, signal);
|
||||
return result.flight ? [result.flight] : [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runFallbackChain(tiers, signal);
|
||||
return { flight: result.flights[0] ?? null };
|
||||
} catch {
|
||||
return { flight: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch flights matching a callsign.
|
||||
* Uses: adsb.lol only (OpenSky callsign search costs 4 credits).
|
||||
*/
|
||||
export async function fetchFlightByCallsign(
|
||||
callsign: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ flight: FlightState | null }> {
|
||||
const normalized = callsign.trim().toUpperCase();
|
||||
if (!normalized) return { flight: null };
|
||||
|
||||
const parseOpts: ParseOptions = {
|
||||
includeGround: true,
|
||||
requireBaroAltitude: false,
|
||||
};
|
||||
const readsbPath = `/callsign/${encodeURIComponent(normalized)}`;
|
||||
const override = getProviderOverride();
|
||||
const tiers: NamedTier[] = [];
|
||||
|
||||
if (override === "airplanes") {
|
||||
tiers.push({
|
||||
id: "airplanes",
|
||||
fn: async () => {
|
||||
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "adsb") {
|
||||
tiers.push({
|
||||
id: "adsb",
|
||||
fn: async () => {
|
||||
const resp = await fetchViaProxy(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// No OpenSky tier: callsign search queries all aircraft (4-credit global fetch)
|
||||
|
||||
try {
|
||||
const result = await runFallbackChain(tiers, signal);
|
||||
return { flight: result.flights[0] ?? null };
|
||||
} catch {
|
||||
return { flight: null };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user