diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..467cd39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.db +*.db-shm +*.db-wal +.env diff --git a/api/bun.lock b/api/bun.lock new file mode 100644 index 0000000..3fbae06 --- /dev/null +++ b/api/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "gastrack-api", + "devDependencies": { + "@types/bun": "^1.3.6", + }, + "peerDependencies": { + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..4d22f24 --- /dev/null +++ b/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "gastrack-api", + "version": "0.1.0", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "build": "bun build --compile --outfile dist/gastrack --production ./src/index.ts" + }, + "dependencies": {}, + "devDependencies": { + "@types/bun": "^1.3.6" + }, + "private": true, + "peerDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/api/src/auth.ts b/api/src/auth.ts new file mode 100644 index 0000000..8c0ffe2 --- /dev/null +++ b/api/src/auth.ts @@ -0,0 +1,34 @@ +import { checkRateLimit, lookupApiKey } from "./db.ts"; + +export function json(data: unknown, status = 200): Response { + return Response.json(data, { status }); +} + +export function err(message: string, status: number): Response { + return json({ error: message }, status); +} + +export function bearerToken(request: Request): string | null { + const header = request.headers.get("Authorization"); + if (!header?.startsWith("Bearer ")) return null; + return header.slice(7).trim(); +} + +export function requireDeviceSecret(request: Request): Response | null { + const token = bearerToken(request); + const secret = process.env.GASTRACK_DEVICE_SECRET; + if (!secret) { + console.error("GASTRACK_DEVICE_SECRET not set"); + return err("Server misconfigured", 500); + } + if (token !== secret) return err("Unauthorized", 401); + return null; +} + +export function requireApiKey(request: Request): Response | null { + const token = bearerToken(request); + if (!token) return err("Missing Authorization header", 401); + if (!lookupApiKey(token)) return err("Invalid API key", 401); + if (!checkRateLimit(token)) return err("Rate limit exceeded", 429); + return null; +} diff --git a/api/src/db.ts b/api/src/db.ts new file mode 100644 index 0000000..b2ec864 --- /dev/null +++ b/api/src/db.ts @@ -0,0 +1,253 @@ +import { Database } from "bun:sqlite"; + +export interface StationRow { + id: string; + name: string; + lat: number; + lng: number; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; + prices_json: string; + fetched_at: number; +} + +export interface Price { + nickname: string; + formattedPrice: string | null; + postedTime: string | null; +} + +export interface Station { + id: string; + name: string; + lat: number; + lng: number; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; + prices: Price[]; + fetchedAt: number; +} + +let _db: Database | null = null; + +export function getDb(): Database { + if (_db) return _db; + + _db = new Database(process.env.DB_PATH ?? "./gastrack.db"); + _db.exec("PRAGMA journal_mode = WAL"); + _db.exec("PRAGMA foreign_keys = ON"); + migrate(_db); + return _db; +} + +function migrate(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS stations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + lat REAL NOT NULL, + lng REAL NOT NULL, + address TEXT, + city TEXT, + state TEXT, + zip TEXT, + prices_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_stations_lat_lng ON stations (lat, lng); + + CREATE TABLE IF NOT EXISTS prefetch_cells ( + cell_key TEXT PRIMARY KEY, + fetched_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS api_keys ( + key TEXT PRIMARY KEY, + email TEXT, + created_at INTEGER, + last_seen INTEGER + ); + + CREATE TABLE IF NOT EXISTS rate_limit ( + key TEXT NOT NULL, + window INTEGER NOT NULL, + count INTEGER DEFAULT 0, + PRIMARY KEY (key, window) + ); + `); +} + +export function rowToStation(row: StationRow): Station { + return { + id: row.id, + name: row.name, + lat: row.lat, + lng: row.lng, + address: row.address, + city: row.city, + state: row.state, + zip: row.zip, + prices: JSON.parse(row.prices_json) as Price[], + fetchedAt: row.fetched_at, + }; +} + +export function upsertStations(stations: Station[]): void { + const db = getDb(); + const stmt = db.prepare(` + INSERT OR REPLACE INTO stations + (id, name, lat, lng, address, city, state, zip, prices_json, fetched_at) + VALUES + ($id, $name, $lat, $lng, $address, $city, $state, $zip, $prices_json, $fetched_at) + `); + + const upsertMany = db.transaction((rows: Station[]) => { + for (const s of rows) { + stmt.run({ + $id: s.id, + $name: s.name, + $lat: s.lat, + $lng: s.lng, + $address: s.address ?? null, + $city: s.city ?? null, + $state: s.state ?? null, + $zip: s.zip ?? null, + $prices_json: JSON.stringify(s.prices), + $fetched_at: s.fetchedAt, + }); + } + }); + + upsertMany(stations); +} + +export function queryStationsInBbox( + minLat: number, + minLng: number, + maxLat: number, + maxLng: number, +): Station[] { + const db = getDb(); + const rows = db + .query( + `SELECT * FROM stations + WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`, + ) + .all(minLat, maxLat, minLng, maxLng); + return rows.map(rowToStation); +} + +export function queryStationsNear( + lat: number, + lng: number, + radiusKm: number, +): Station[] { + // Use a bounding box approximation (1 deg lat ≈ 111km) + const latDelta = radiusKm / 111; + const lngDelta = radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); + return queryStationsInBbox( + lat - latDelta, + lng - lngDelta, + lat + latDelta, + lng + lngDelta, + ); +} + +export function isCellFresh(cellKey: string, ttlMs: number): boolean { + const db = getDb(); + const row = db + .query<{ fetched_at: number }, [string]>( + "SELECT fetched_at FROM prefetch_cells WHERE cell_key = ?", + ) + .get(cellKey); + if (!row) return false; + return Date.now() - row.fetched_at < ttlMs; +} + +export function markCellFetched(cellKey: string): void { + const db = getDb(); + db.run( + "INSERT OR REPLACE INTO prefetch_cells (cell_key, fetched_at) VALUES (?, ?)", + [cellKey, Date.now()], + ); +} + +export function getCacheStats(): { + cachedStations: number; + oldestFetch: number | null; + newestFetch: number | null; +} { + const db = getDb(); + const row = db + .query< + { count: number; oldest: number | null; newest: number | null }, + [] + >("SELECT COUNT(*) as count, MIN(fetched_at) as oldest, MAX(fetched_at) as newest FROM stations") + .get(); + return { + cachedStations: row?.count ?? 0, + oldestFetch: row?.oldest ?? null, + newestFetch: row?.newest ?? null, + }; +} + +export function lookupApiKey(key: string): boolean { + const db = getDb(); + const row = db + .query<{ key: string }, [string]>( + "SELECT key FROM api_keys WHERE key = ?", + ) + .get(key); + if (row) { + db.run("UPDATE api_keys SET last_seen = ? WHERE key = ?", [ + Date.now(), + key, + ]); + } + return row !== null; +} + +export function createApiKey(email: string | null): string { + const db = getDb(); + const key = `gt_${crypto.randomUUID().replace(/-/g, "")}`; + db.run( + "INSERT INTO api_keys (key, email, created_at) VALUES (?, ?, ?)", + [key, email, Date.now()], + ); + return key; +} + +// Returns true if under limit, false if rate limited +export function checkRateLimit(key: string): boolean { + const db = getDb(); + const now = Date.now(); + const currentWindow = Math.floor(now / 60_000); + const cutoff = currentWindow - 60; // last 60 minute buckets + + // Lazily expire old buckets + db.run( + "DELETE FROM rate_limit WHERE key = ? AND window < ?", + [key, cutoff], + ); + + // Increment current bucket + db.run( + `INSERT INTO rate_limit (key, window, count) VALUES (?, ?, 1) + ON CONFLICT (key, window) DO UPDATE SET count = count + 1`, + [key, currentWindow], + ); + + // Sum the last 60 buckets + const row = db + .query<{ total: number }, [string, number]>( + "SELECT SUM(count) as total FROM rate_limit WHERE key = ? AND window >= ?", + ) + .get(key, cutoff); + + return (row?.total ?? 0) <= 60; +} diff --git a/api/src/gasbuddy.ts b/api/src/gasbuddy.ts new file mode 100644 index 0000000..97a2ea7 --- /dev/null +++ b/api/src/gasbuddy.ts @@ -0,0 +1,113 @@ +import type { Price, Station } from "./db.ts"; + +const GASBUDDY_URL = "https://www.gasbuddy.com/graphql"; + +const STATIONS_BY_LOCATION_QUERY = ` +query StationsByLocation($lat: Float!, $lng: Float!, $fuel: Int) { + stations: stationsByLocation(lat: $lat, lng: $lng, fuel: $fuel) { + results { + id + name + latitude + longitude + address { + line1 + city + state + zip + } + prices(fuel: $fuel) { + nickname + formattedPrice + postedTime + } + } + } +} +`; + +interface GasBuddyStationResult { + id: string; + name: string; + latitude: number; + longitude: number; + address: { + line1: string | null; + city: string | null; + state: string | null; + zip: string | null; + }; + prices: Array<{ + nickname: string; + formattedPrice: string | null; + postedTime: string | null; + }>; +} + +interface GasBuddyResponse { + data: { + stations: { + results: GasBuddyStationResult[]; + }; + }; + errors?: Array<{ message: string }>; +} + +const HEADERS = { + "Content-Type": "application/json", + "User-Agent": + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + Origin: "https://www.gasbuddy.com", + Referer: "https://www.gasbuddy.com/", +}; + +// Serialize GasBuddy fetches with a 500ms delay between calls +let lastFetchTime = 0; + +async function throttledFetch(body: string): Promise { + const now = Date.now(); + const elapsed = now - lastFetchTime; + if (elapsed < 500) { + await Bun.sleep(500 - elapsed); + } + lastFetchTime = Date.now(); + return fetch(GASBUDDY_URL, { method: "POST", headers: HEADERS, body }); +} + +export async function fetchStationsByLocation( + lat: number, + lng: number, +): Promise { + const body = JSON.stringify({ + query: STATIONS_BY_LOCATION_QUERY, + variables: { lat, lng, fuel: 1 }, + }); + + const res = await throttledFetch(body); + + if (!res.ok) { + throw new Error(`GasBuddy returned ${res.status}: ${await res.text()}`); + } + + const json = (await res.json()) as GasBuddyResponse; + + if (json.errors?.length) { + throw new Error( + `GasBuddy GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`, + ); + } + + const now = Date.now(); + return json.data.stations.results.map((r) => ({ + id: r.id, + name: r.name, + lat: r.latitude, + lng: r.longitude, + address: r.address.line1 ?? null, + city: r.address.city ?? null, + state: r.address.state ?? null, + zip: r.address.zip ?? null, + prices: r.prices as Price[], + fetchedAt: now, + })); +} diff --git a/api/src/geo.ts b/api/src/geo.ts new file mode 100644 index 0000000..eabdcac --- /dev/null +++ b/api/src/geo.ts @@ -0,0 +1,104 @@ +// ~5km grid cell size (in degrees) +const CELL_SIZE = 0.045; // ~5km at US latitudes + +export function latLngToCell(lat: number, lng: number): string { + const latGrid = Math.floor(lat / CELL_SIZE); + const lngGrid = Math.floor(lng / CELL_SIZE); + return `${latGrid}:${lngGrid}`; +} + +export function cellCenter(cellKey: string): { lat: number; lng: number } { + const [latGrid, lngGrid] = cellKey.split(":").map(Number); + return { + lat: (latGrid! + 0.5) * CELL_SIZE, + lng: (lngGrid! + 0.5) * CELL_SIZE, + }; +} + +// Haversine distance in km +export function distanceKm( + lat1: number, + lng1: number, + lat2: number, + lng2: number, +): number { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +// Sample points along a polyline at a given interval (km) +export function sampleRoute( + points: [number, number][], + intervalKm: number, +): Array<{ lat: number; lng: number }> { + if (points.length === 0) return []; + + const samples: Array<{ lat: number; lng: number }> = []; + let accumulated = 0; + + const first = points[0]!; + samples.push({ lat: first[0], lng: first[1] }); + + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]!; + const curr = points[i]!; + const segLen = distanceKm(prev[0], prev[1], curr[0], curr[1]); + accumulated += segLen; + + while (accumulated >= intervalKm) { + accumulated -= intervalKm; + // Interpolate backwards along segment + const t = (segLen - accumulated) / segLen; + samples.push({ + lat: prev[0] + t * (curr[0] - prev[0]), + lng: prev[1] + t * (curr[1] - prev[1]), + }); + } + } + + const last = points[points.length - 1]!; + const lastSample = samples[samples.length - 1]!; + if ( + distanceKm(lastSample.lat, lastSample.lng, last[0], last[1]) > 0.1 + ) { + samples.push({ lat: last[0], lng: last[1] }); + } + + return samples; +} + +export function totalRouteDistanceKm(points: [number, number][]): number { + let total = 0; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]!; + const curr = points[i]!; + total += distanceKm(prev[0], prev[1], curr[0], curr[1]); + } + return total; +} + +export function routeBbox(points: [number, number][]): { + minLat: number; + minLng: number; + maxLat: number; + maxLng: number; +} { + let minLat = Infinity, + minLng = Infinity, + maxLat = -Infinity, + maxLng = -Infinity; + for (const [lat, lng] of points) { + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + } + return { minLat, minLng, maxLat, maxLng }; +} diff --git a/api/src/handlers/health.ts b/api/src/handlers/health.ts new file mode 100644 index 0000000..796d479 --- /dev/null +++ b/api/src/handlers/health.ts @@ -0,0 +1,16 @@ +import { json } from "../auth.ts"; +import { getCacheStats } from "../db.ts"; + +export async function handleHealth(_req: Request): Promise { + const stats = getCacheStats(); + return json({ + ok: true, + cached_stations: stats.cachedStations, + oldest_fetch: stats.oldestFetch + ? new Date(stats.oldestFetch).toISOString() + : null, + newest_fetch: stats.newestFetch + ? new Date(stats.newestFetch).toISOString() + : null, + }); +} diff --git a/api/src/handlers/keys.ts b/api/src/handlers/keys.ts new file mode 100644 index 0000000..4e18757 --- /dev/null +++ b/api/src/handlers/keys.ts @@ -0,0 +1,18 @@ +import { err, json, requireDeviceSecret } from "../auth.ts"; +import { createApiKey } from "../db.ts"; + +export async function handleRegisterKey(req: Request): Promise { + const authErr = requireDeviceSecret(req); + if (authErr) return authErr; + + let email: string | null = null; + try { + const body = (await req.json()) as { email?: string }; + email = typeof body.email === "string" ? body.email : null; + } catch { + return err("Invalid JSON body", 400); + } + + const key = createApiKey(email); + return json({ key }, 201); +} diff --git a/api/src/handlers/prefetch.ts b/api/src/handlers/prefetch.ts new file mode 100644 index 0000000..84ef132 --- /dev/null +++ b/api/src/handlers/prefetch.ts @@ -0,0 +1,119 @@ +import { err, json, requireApiKey } from "../auth.ts"; +import { isCellFresh, markCellFetched, queryStationsInBbox, upsertStations } from "../db.ts"; +import { fetchStationsByLocation } from "../gasbuddy.ts"; +import { + latLngToCell, + routeBbox, + sampleRoute, + totalRouteDistanceKm, +} from "../geo.ts"; + +const PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours +const MAX_POINTS = 500; +const MAX_DISTANCE_KM = 200; +const MAX_SAMPLES = 25; +const DEFAULT_INTERVAL_KM = 8; + +export async function handlePrefetchRoute(req: Request): Promise { + const authErr = requireApiKey(req); + if (authErr) return authErr; + + let body: { points?: unknown; interval_km?: unknown }; + try { + body = (await req.json()) as typeof body; + } catch { + return err("Invalid JSON body", 400); + } + + if (!Array.isArray(body.points)) { + return err("points must be an array", 400); + } + + const points = body.points as unknown[]; + if (points.length > MAX_POINTS) { + return err(`Too many input points (max ${MAX_POINTS})`, 400); + } + + // Validate each point is [number, number] + const validated: [number, number][] = []; + for (const p of points) { + if ( + !Array.isArray(p) || + p.length < 2 || + typeof p[0] !== "number" || + typeof p[1] !== "number" + ) { + return err("Each point must be [lat, lng] numbers", 400); + } + validated.push([p[0], p[1]]); + } + + if (validated.length < 2) { + return err("At least 2 points required", 400); + } + + const totalDistKm = totalRouteDistanceKm(validated); + if (totalDistKm > MAX_DISTANCE_KM) { + return err(`Route too long: ${totalDistKm.toFixed(1)}km (max ${MAX_DISTANCE_KM}km)`, 400); + } + + const intervalKm = + typeof body.interval_km === "number" && body.interval_km > 0 + ? body.interval_km + : DEFAULT_INTERVAL_KM; + + const samples = sampleRoute(validated, intervalKm); + if (samples.length > MAX_SAMPLES) { + return err( + `Route generates too many samples: ${samples.length} (max ${MAX_SAMPLES}). Increase interval_km.`, + 400, + ); + } + + // Fetch uncached cells + const seenCells = new Set(); + let fetchCount = 0; + + for (const sample of samples) { + const cellKey = latLngToCell(sample.lat, sample.lng); + if (seenCells.has(cellKey)) continue; + seenCells.add(cellKey); + + if (isCellFresh(cellKey, PREFETCH_TTL_MS)) continue; + + try { + const stations = await fetchStationsByLocation(sample.lat, sample.lng); + upsertStations(stations); + markCellFetched(cellKey); + fetchCount++; + } catch (e) { + console.error(`GasBuddy fetch failed for cell ${cellKey}:`, e); + } + } + + const bbox = routeBbox(validated); + + // Expand bbox slightly to cover station corridor + const pad = 0.1; + const expandedBbox = { + minLat: bbox.minLat - pad, + minLng: bbox.minLng - pad, + maxLat: bbox.maxLat + pad, + maxLng: bbox.maxLng + pad, + }; + + const stations = queryStationsInBbox( + expandedBbox.minLat, + expandedBbox.minLng, + expandedBbox.maxLat, + expandedBbox.maxLng, + ); + + return json({ + bbox: expandedBbox, + samples: fetchCount, + stations, + count: stations.length, + cached_at: Date.now(), + }); +} diff --git a/api/src/handlers/stations.ts b/api/src/handlers/stations.ts new file mode 100644 index 0000000..5c3b50c --- /dev/null +++ b/api/src/handlers/stations.ts @@ -0,0 +1,89 @@ +import { err, json, requireApiKey } from "../auth.ts"; +import { + isCellFresh, + markCellFetched, + queryStationsInBbox, + queryStationsNear, + upsertStations, +} from "../db.ts"; +import { fetchStationsByLocation } from "../gasbuddy.ts"; +import { latLngToCell } from "../geo.ts"; + +const NEARBY_TTL_MS = 30 * 60 * 1000; // 30 minutes +const MAX_BBOX_AREA = 0.5; + +export async function handleNearby(req: Request): Promise { + const authErr = requireApiKey(req); + if (authErr) return authErr; + + const url = new URL(req.url); + const lat = parseFloat(url.searchParams.get("lat") ?? ""); + const lng = parseFloat(url.searchParams.get("lng") ?? ""); + const radiusKm = parseFloat(url.searchParams.get("radius_km") ?? "8"); + + if (!isFinite(lat) || !isFinite(lng)) { + return err("lat and lng are required", 400); + } + if (!isFinite(radiusKm) || radiusKm <= 0) { + return err("radius_km must be a positive number", 400); + } + + // Validate bbox area + const latDelta = radiusKm / 111; + const lngDelta = + radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); + if (latDelta * 2 * lngDelta * 2 > MAX_BBOX_AREA) { + return err( + "Requested area exceeds 0.5 deg² limit. Reduce radius_km.", + 400, + ); + } + + const cellKey = latLngToCell(lat, lng); + + // Serve from cache if fresh + if (!isCellFresh(cellKey, NEARBY_TTL_MS)) { + try { + const stations = await fetchStationsByLocation(lat, lng); + upsertStations(stations); + markCellFetched(cellKey); + } catch (e) { + console.error("GasBuddy fetch failed:", e); + // Fall through to stale cache rather than error + } + } + + const stations = queryStationsNear(lat, lng, radiusKm); + return json(stations); +} + +export async function handleBbox(req: Request): Promise { + const authErr = requireApiKey(req); + if (authErr) return authErr; + + const url = new URL(req.url); + const minLat = parseFloat(url.searchParams.get("min_lat") ?? ""); + const minLng = parseFloat(url.searchParams.get("min_lng") ?? ""); + const maxLat = parseFloat(url.searchParams.get("max_lat") ?? ""); + const maxLng = parseFloat(url.searchParams.get("max_lng") ?? ""); + + if ( + !isFinite(minLat) || + !isFinite(minLng) || + !isFinite(maxLat) || + !isFinite(maxLng) + ) { + return err("min_lat, min_lng, max_lat, max_lng are required", 400); + } + + const area = (maxLat - minLat) * (maxLng - minLng); + if (area <= 0 || area > MAX_BBOX_AREA) { + return err( + "Bbox area must be positive and ≤ 0.5 deg²", + 400, + ); + } + + const stations = queryStationsInBbox(minLat, minLng, maxLat, maxLng); + return json(stations); +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..d14ca82 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,34 @@ +import { serve } from "bun"; +import { getDb } from "./db.ts"; +import { handleHealth } from "./handlers/health.ts"; +import { handleRegisterKey } from "./handlers/keys.ts"; +import { handleBbox, handleNearby } from "./handlers/stations.ts"; +import { handlePrefetchRoute } from "./handlers/prefetch.ts"; + +// Initialize DB on startup +getDb(); + +const server = serve({ + port: process.env.PORT ? parseInt(process.env.PORT, 10) : 7878, + + routes: { + "/health": { GET: handleHealth }, + "/keys/register": { POST: handleRegisterKey }, + "/stations/nearby": { GET: handleNearby }, + "/stations/bbox": { GET: handleBbox }, + "/prefetch/route": { POST: handlePrefetchRoute }, + }, + + fetch(req) { + return new Response("Not found", { status: 404 }); + }, +}); + +console.log(`gastrack listening on ${server.hostname}:${server.port}`); + +process.on("SIGINT", () => process.exit(0)); +process.on("SIGTERM", () => process.exit(0)); + +process.on("unhandledRejection", (reason) => { + console.error("Unhandled rejection:", reason); +}); diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..d5ff1e1 --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true + } +}