This commit is contained in:
Cayden Schwechten 2026-10-31 21:20:16 -05:00
commit 6250d23297
53 changed files with 6381 additions and 0 deletions

28
app/api/geocode/route.ts Normal file
View file

@ -0,0 +1,28 @@
import { NextResponse, type NextRequest } from "next/server";
import { reversePlace, searchPlaces } from "@/lib/weather/geocode";
import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond";
/**
* GET /api/geocode?q=Burnsville → Place[] (search by name)
* GET /api/geocode?lat=44.7&lon=-93.2 → Place (name for coordinates)
*/
export async function GET(request: NextRequest) {
const blocked = forbidCrossSite(request);
if (blocked) return blocked;
const params = request.nextUrl.searchParams;
const query = params.get("q")?.trim();
try {
if (query) {
if (query.length < 2) return NextResponse.json([]);
return NextResponse.json(await searchPlaces(query));
}
const coords = parseCoords(params);
if (!coords) return badRequest("Pass either q, or lat and lon");
return NextResponse.json(await reversePlace(coords.lat, coords.lon));
} catch (error) {
return errorResponse(error);
}
}

View file

@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { listProviders } from "@/lib/weather/providers";
// Read env vars per request so adding a key doesn't require a rebuild.
export const dynamic = "force-dynamic";
/** GET /api/providers → which providers exist and which have keys configured. */
export function GET() {
return NextResponse.json(listProviders());
}

43
app/api/radar/route.ts Normal file
View file

@ -0,0 +1,43 @@
import { NextResponse, type NextRequest } from "next/server";
import { fetchJson } from "@/lib/weather/http";
import { errorResponse, forbidCrossSite } from "@/lib/weather/respond";
import type { RadarFrame } from "@/lib/weather/types";
/**
* GET /api/radar → RainViewer frames, oldest first, ready for Leaflet.
*
* RainViewer publishes an index of frames. Each frame's `path` is a hash, not a
* timestamp, so tile URLs must always be built from this index — never guessed.
* Docs: https://www.rainviewer.com/api/weather-maps-api.html
*/
interface RainViewerIndex {
host: string;
radar: { past: { time: number; path: string }[] };
}
// Last 7 frames ≈ one hour. Keeps tile requests well under RainViewer's
// limit of 100 requests per minute per IP.
const FRAME_COUNT = 7;
const COLOR_SCHEME = 2; // "Universal Blue", the only scheme still offered
const OPTIONS = "1_1"; // smoothed, snow shown in its own colors
export async function GET(request: NextRequest) {
const blocked = forbidCrossSite(request);
if (blocked) return blocked;
try {
const index = await fetchJson<RainViewerIndex>("https://api.rainviewer.com/public/weather-maps.json", "RainViewer", {
cacheSeconds: 300, // new frames arrive every 10 minutes
});
const frames: RadarFrame[] = index.radar.past.slice(-FRAME_COUNT).map((frame) => ({
time: new Date(frame.time * 1000).toISOString(),
url: `${index.host}${frame.path}/256/{z}/{x}/{y}/${COLOR_SCHEME}/${OPTIONS}.png`,
}));
return NextResponse.json(frames);
} catch (error) {
return errorResponse(error);
}
}

View file

@ -0,0 +1,92 @@
import type { NextRequest } from "next/server";
import { OWM_TILE_LAYERS } from "@/lib/radar/layers";
import { forbidCrossSite } from "@/lib/weather/respond";
const CARTO_LAYERS: Record<string, string> = {
"carto-dark-nolabels": "dark_nolabels",
"carto-dark-labels": "dark_only_labels",
};
const CARTO_SUBDOMAINS = ["a", "b", "c", "d"] as const;
const MAX_ZOOM = 18;
/**
* GET /api/tiles/[layer]/[z]/[x]/[y] → one map tile (PNG).
*
* Proxies both OpenWeather weather overlays (OPENWEATHER_API_KEY) and CARTO
* dark basemap/label tiles (CARTO_API_KEY) server-side so keys stay in `.env`
* or `.env.local` and cannot be hotlinked from other domains.
*/
export async function GET(
request: NextRequest,
context: { params: Promise<{ layer: string; z: string; x: string; y: string }> },
) {
const blocked = forbidCrossSite(request);
if (blocked) return blocked;
const { layer, z, x, y } = await context.params;
const [zi, xi, yi] = [z, x, y].map(Number);
const maxIndex = 2 ** zi;
if (
![zi, xi, yi].every((n) => Number.isInteger(n) && n >= 0) ||
zi > MAX_ZOOM ||
xi >= maxIndex ||
yi >= maxIndex
) {
return new Response("Bad tile coordinates", { status: 400 });
}
// 1. CARTO dark basemap & place-label tiles
const cartoStyle = CARTO_LAYERS[layer];
if (cartoStyle) {
const key = (process.env.CARTO_API_KEY ?? process.env.NEXT_PUBLIC_CARTO_API_KEY ?? "").trim();
const scale = request.nextUrl.searchParams.get("r") === "@2x" ? "@2x" : "";
const sub = CARTO_SUBDOMAINS[(xi + yi) % CARTO_SUBDOMAINS.length];
const query = key ? `?key=${encodeURIComponent(key)}` : "";
const url = `https://${sub}.basemaps.cartocdn.com/${cartoStyle}/${z}/${x}/${y}${scale}.png${query}`;
const headers: HeadersInit = {};
const referer = request.headers.get("referer");
const origin = request.headers.get("origin");
if (referer) headers.Referer = referer;
if (origin) headers.Origin = origin;
const upstream = await fetch(
url,
key ? { headers, next: { revalidate: 86_400 } } : { headers, cache: "no-store" },
).catch(() => null);
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
// CARTO tags unauthenticated placeholder tiles with an `"wm-..."` ETag; never cache those.
const isWatermarked = !key || Boolean(upstream.headers.get("etag")?.includes("wm-"));
return new Response(upstream.body, {
headers: {
"Content-Type": "image/png",
"Cache-Control": isWatermarked ? "no-store" : "public, max-age=86400",
},
});
}
// 2. OpenWeather weather-model overlay tiles
const owmLayer = OWM_TILE_LAYERS[layer];
const owmKey = process.env.OPENWEATHER_API_KEY?.trim();
if (!owmLayer) return new Response("Unknown layer", { status: 404 });
if (!owmKey) return new Response("OPENWEATHER_API_KEY is not set", { status: 503 });
const upstream = await fetch(`https://tile.openweathermap.org/map/${owmLayer}/${z}/${x}/${y}.png?appid=${owmKey}`, {
next: { revalidate: 600 },
}).catch(() => null);
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
return new Response(upstream.body, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=600",
},
});
}

57
app/api/weather/route.ts Normal file
View file

@ -0,0 +1,57 @@
import { NextResponse, type NextRequest } from "next/server";
import { getLocalContext, type LocalContext } from "@/lib/weather/extras/air-quality";
import { getNwsAlerts } from "@/lib/weather/extras/nws-alerts";
import { isProviderId, isReady, PROVIDERS } from "@/lib/weather/providers";
import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond";
import type { WeatherAlert, WeatherReport } from "@/lib/weather/types";
/**
* GET /api/weather?provider=openweather&lat=44.77&lon=-93.27
*
* Runs on the server so API keys stay in .env / .env.local and never reach the browser.
*
* Three things are fetched at once:
* 1. the forecast from the chosen provider (required — failure is an error)
* 2. air quality + time zone from Open-Meteo (optional — failure just hides it)
* 3. NWS alerts for US locations (optional — failure just hides them)
*/
export async function GET(request: NextRequest) {
const blocked = forbidCrossSite(request);
if (blocked) return blocked;
const params = request.nextUrl.searchParams;
const providerId = params.get("provider") ?? "openweather";
const coords = parseCoords(params);
if (!isProviderId(providerId)) return badRequest(`Unknown provider "${providerId}"`);
if (!coords) return badRequest("lat and lon are required");
const provider = PROVIDERS[providerId];
if (!isReady(provider)) return badRequest(`${provider.label} has no API key configured`);
const [forecast, context, nwsAlerts] = await Promise.allSettled([
provider.getForecast(coords.lat, coords.lon),
getLocalContext(coords.lat, coords.lon),
getNwsAlerts(coords.lat, coords.lon),
]);
if (forecast.status === "rejected") return errorResponse(forecast.reason);
const local: LocalContext | null = context.status === "fulfilled" ? context.value : null;
const official: WeatherAlert[] = nwsAlerts.status === "fulfilled" ? nwsAlerts.value : [];
// Prefer official NWS alerts in the US; elsewhere use whatever the provider gave us.
const alerts = official.length > 0 ? official : forecast.value.alerts;
const alertSource = official.length > 0 ? "National Weather Service" : alerts.length > 0 ? provider.label : null;
const report: WeatherReport = {
...forecast.value,
provider: providerId,
timeZone: forecast.value.timeZone ?? local?.timeZone ?? null,
airQuality: local?.airQuality ?? null,
alerts,
alertSource,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(report);
}