import express from 'express'; import {resolve, dirname} from 'path'; import {fileURLToPath} from 'url'; import {cfg, localDateStr} from './config.mjs'; import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs'; import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs'; import {getSpaceWeather} from './space.mjs'; import {apiReference} from '@scalar/express-api-reference'; import {spec} from './spec.mjs'; import {existsSync} from 'fs'; import {getAIS} from './ais.mjs'; import {getADSB, getAirTrafficHistory, getShapes, initAircraftDb} from './adsb.mjs'; import {getWeatherCondition} from './openweather.mjs'; import {lastForecast, getForecast, forecastTTL} from './forecast.mjs'; import {dailyWeather, hourlyWeather} from './openmeteo.mjs'; // import {Aurora} from './aurora.mjs'; // ── Uncaught error handlers ─────────────────────────────────────────────────── process.on('unhandledRejection', (reason, promise) => { console.error('[unhandledRejection]', promise, '\nReason:', reason) }) process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err) }) const app = express(); const DIR = dirname(fileURLToPath(import.meta.url)); const CLIENT_DIST = resolve(DIR, '../public'); app.use(express.json()); app.use('/icons', express.static(resolve(DIR, '../public', 'icons'))); app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); }); app.use(express.static(CLIENT_DIST)); function filterFields(obj, fields) { if(!fields) return obj; const keys = new Set(fields.split(',').map(f => f.trim())); return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.has(k))); } function filterArr(arr, fields) { if(!fields) return arr; return arr.map(row => filterFields(row, fields)); } // ── Current ─────────────────────────────────────────────────────────────────── app.get('/api/current', async (req, res) => { const { fields } = req.query const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()]) const space = getCelestialCurrent(coords.latitude, coords.longitude) const condition = getWeatherCondition(Object.assign(sensors, space)) const forecast = await getForecast(); const merged = { ...(forecast?.summary ? { precipitation_chance: forecast.summary.precipitation_chance, temperature_max: forecast.summary.temperature_max, temperature_min: forecast.summary.temperature_min, uv_index_max: forecast.summary.uv_index, } : {}), ...condition, ...sensors, ...space, } res.json(filterFields(merged, fields)) }) // ── Hourly ──────────────────────────────────────────────────────────────────── app.get('/api/hourly', async (req, res) => { const { fields } = req.query const now = new Date() const plus24 = new Date(now.getTime() + 24 * 3600 * 1000) const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0, 0, 0, 0)) const end = req.query.end ? new Date(req.query.end) : plus24 const coords = await getCoords() const historyEnd = new Date(Math.min(now, end)) historyEnd.setMinutes(0, 0, 0) const [historyResult] = await Promise.allSettled([ start < now ? queryHourly(start, historyEnd) : Promise.resolve([]) ]) const history = historyResult.status === 'fulfilled' ? historyResult.value : [] const physics = end > now ? lastForecast.slots.filter(s => { const t = new Date(s.time); return t >= now && t <= new Date(Math.min(end, plus24)) }) : [] let meteo = [] if (end > plus24) { const [meteoResult] = await Promise.allSettled([hourlyWeather(coords.latitude, coords.longitude, plus24, end)]) meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : [] } const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...physics, ...meteo]) res.json(filterArr(hourly, fields)) }) // ── Daily ───────────────────────────────────────────────────────────────────── app.get('/api/daily', async (req, res) => { const { fields } = req.query const now = new Date() const todayStart = new Date(now) todayStart.setHours(0, 0, 0, 0) const next = new Date(todayStart) next.setDate(todayStart.getDate() + 1) const start = req.query.start ? new Date(req.query.start) : todayStart const end = req.query.end ? new Date(req.query.end) : next const coords = await getCoords() const todayStr = localDateStr(now) const usePhysics = lastForecast.summary && start <= now && end > now const [sensorResult] = await Promise.allSettled([start < now ? queryDaily(start, now) : Promise.resolve([])]) const history = sensorResult.status === 'fulfilled' ? sensorResult.value : [] const meteoFrom = new Date(Math.max(next.getTime(), start.getTime())) const [meteoResult] = await Promise.allSettled([end > next ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])]) const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : [] const todaySlot = usePhysics ? [lastForecast.summary] : [] const meteoClean = meteo.filter(r => localDateStr(new Date(r.time)) !== todayStr) const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean]) res.json(filterArr(daily, fields)) }) // ── Position ────────────────────────────────────────────────────────────────── app.get('/api/position', async (req, res) => { const {fields} = req.query; const data = await getCoords(); res.json(filterFields(data, fields)); }); // ── Space ───────────────────────────────────────────────────────────────────── // app.get('/api/aurora', async (req, res) => res.json(await Aurora.get())); app.get('/api/space', async (req, res) => { const {fields} = req.query; res.json(filterFields(await getSpaceWeather(), fields)); }); // ── ADSB Proxy ──────────────────────────────────────────────────────────────── app.get('/api/adsb', async (req, res) => res.json(await getADSB())); app.get('/api/adsb/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao))); app.get('/api/ais', async (req, res) => res.json(await getAIS())); app.get('/api/vehicles', async (req, res) => res.json(await getShapes())); // ── DOCS ────────────────────────────────────────────────────────────────────── app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'})); app.get('/openapi.json', (req, res) => res.json(spec)); app.get('/openapi.yaml', async (req, res) => { const {stringify} = await import('yaml'); res.setHeader('Content-Type', 'text/yaml'); res.send(stringify(spec)); }); // ── Website ─────────────────────────────────────────────────────────────────── app.get('*', (req, res) => { const index = resolve(CLIENT_DIST, 'index.html'); if(existsSync(index)) res.sendFile(index); else res.status(404).send('Client not built yet — run npm run build in /client'); }); // ── Errors ──────────────────────────────────────────────────────────────────── app.use((req, res, next) => { res.status(404).json({ error: `Not found: ${req.method} ${req.path}` }) }) app.use((err, req, res, next) => { console.error(`[ERROR] ${req.method} ${req.path}\n${err.stack}`) res.status(500).json({ error: err.message, stack: err.stack }) }) // ── Start ───────────────────────────────────────────────────────────────────── (async () => { const c = cfg(); setTimeout(getForecast, 1) setInterval(getForecast, forecastTTL) await initAircraftDb(); app.listen(c.PORT, () => { console.log(`🌦 Weather API — http://localhost:${c.PORT}`) }); })()