* 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)
142 lines
3.2 KiB
TypeScript
142 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import maplibregl from "maplibre-gl";
|
|
import "maplibre-gl/dist/maplibre-gl.css";
|
|
import {
|
|
createContext,
|
|
forwardRef,
|
|
useContext,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { DEFAULT_STYLE, type MapStyleSpec } from "@/lib/map-styles";
|
|
|
|
type MapContextValue = {
|
|
map: maplibregl.Map | null;
|
|
isLoaded: boolean;
|
|
};
|
|
|
|
const MapContext = createContext<MapContextValue | null>(null);
|
|
|
|
export function useMap() {
|
|
const context = useContext(MapContext);
|
|
if (!context)
|
|
throw new Error("useMap must be used within a <Map /> provider");
|
|
return context;
|
|
}
|
|
|
|
type MapProps = {
|
|
children?: ReactNode;
|
|
className?: string;
|
|
mapStyle?: MapStyleSpec;
|
|
center?: [number, number];
|
|
zoom?: number;
|
|
pitch?: number;
|
|
bearing?: number;
|
|
minZoom?: number;
|
|
maxZoom?: number;
|
|
};
|
|
|
|
export type MapRef = maplibregl.Map;
|
|
|
|
export const Map = forwardRef<MapRef, MapProps>(function Map(
|
|
{
|
|
children,
|
|
className,
|
|
mapStyle = DEFAULT_STYLE.style,
|
|
center = [0, 20],
|
|
zoom = 2.5,
|
|
pitch = 49,
|
|
bearing = -20,
|
|
minZoom = 2,
|
|
maxZoom = 16,
|
|
},
|
|
ref,
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null);
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
|
|
useImperativeHandle(ref, () => mapInstance as maplibregl.Map, [mapInstance]);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const map = new maplibregl.Map({
|
|
container: containerRef.current,
|
|
style: DEFAULT_STYLE.style as maplibregl.StyleSpecification | string,
|
|
center,
|
|
zoom,
|
|
pitch,
|
|
bearing,
|
|
minZoom,
|
|
maxZoom,
|
|
maxPitch: 85,
|
|
attributionControl: false,
|
|
renderWorldCopies: false,
|
|
});
|
|
|
|
map.on("load", () => setIsLoaded(true));
|
|
setMapInstance(map);
|
|
|
|
return () => {
|
|
map.remove();
|
|
setIsLoaded(false);
|
|
setMapInstance(null);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!mapInstance || !isLoaded) return;
|
|
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
|
|
|
|
const applyTerrain = () => {
|
|
if (typeof mapStyle === "object" && "terrain" in mapStyle) {
|
|
const spec = mapStyle as Record<string, unknown>;
|
|
try {
|
|
mapInstance.setTerrain(
|
|
spec.terrain as maplibregl.TerrainSpecification,
|
|
);
|
|
} catch {
|
|
/* terrain source not yet loaded */
|
|
}
|
|
} else {
|
|
try {
|
|
mapInstance.setTerrain(null);
|
|
} catch {
|
|
/* no terrain to remove */
|
|
}
|
|
}
|
|
};
|
|
mapInstance.once("style.load", applyTerrain);
|
|
|
|
return () => {
|
|
mapInstance.off("style.load", applyTerrain);
|
|
};
|
|
}, [mapInstance, isLoaded, mapStyle]);
|
|
|
|
const ctx = useMemo(
|
|
() => ({ map: mapInstance, isLoaded }),
|
|
[mapInstance, isLoaded],
|
|
);
|
|
|
|
return (
|
|
<MapContext.Provider value={ctx}>
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("relative h-full w-full", className)}
|
|
>
|
|
{mapInstance && children}
|
|
</div>
|
|
</MapContext.Provider>
|
|
);
|
|
});
|
|
|
|
Map.displayName = "Map";
|