From 393cc5f90183e5dfe64f17b785790bc6860efeeb Mon Sep 17 00:00:00 2001
From: kew <108450560+kewonit@users.noreply.github.com>
Date: Sat, 14 Feb 2026 15:05:00 +0530
Subject: [PATCH] fix: call OpenSky API directly from browser to bypass Vercel
IP blocks (#1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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 tag (already set via metadata.alternates)
- Sanitize JSON-LD output with \u003c escaping to prevent 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)
---
.env.example | 19 +-
src/app/api/flights/route.ts | 307 ----------------------------
src/app/layout.tsx | 3 +-
src/app/page.tsx | 4 +-
src/components/flight-tracker.tsx | 6 +-
src/components/map/map.tsx | 3 +-
src/components/ui/control-panel.tsx | 12 +-
src/hooks/use-flights.ts | 18 +-
src/hooks/use-trail-history.ts | 9 +-
src/lib/opensky.ts | 81 +++++---
10 files changed, 88 insertions(+), 374 deletions(-)
delete mode 100644 src/app/api/flights/route.ts
diff --git a/.env.example b/.env.example
index 6c0be13..a895ad4 100644
--- a/.env.example
+++ b/.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=
+# --- Analytics (optional) ---
+# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
-# 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) ─────────────────────────────────────────────────────
-# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
\ No newline at end of file
+# --- Notes ---
+# OpenSky API calls are made directly from the browser (CORS supported).
+# No server-side credentials are needed.
diff --git a/src/app/api/flights/route.ts b/src/app/api/flights/route.ts
deleted file mode 100644
index af92896..0000000
--- a/src/app/api/flights/route.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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();
-
-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 {
- 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,
-) {
- 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,
- );
- }
-}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 93071d0..9eff7f6 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -61,8 +61,7 @@ export default function RootLayout({
return (
-
- {GA_ID && (
+ {GA_ID && /^G-[A-Z0-9]+$/.test(GA_ID) && (
<>