import express from 'express'; import {resolve, dirname} from 'path'; import {fileURLToPath} from 'url'; import {cfg} from './config.mjs'; import {queryCurrent, queryHourly, queryDaily, getCoords} from './sensors.mjs'; import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs'; import {apiReference} from '@scalar/express-api-reference'; import {spec} from './spec.mjs'; import {existsSync} from 'fs'; import {getAIS} from './ais.mjs'; import {getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs'; import {fetchIcon} from './openweather.mjs'; import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs'; import {dailyWeather, hourlyWeather} from './openmeteo.mjs'; // ── Uncaught error handlers ─────────────────────────────────────────────────── const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); 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((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', asyncHandler(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 = { ...condition, ...sensors, ...(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, } : {}), ...space, } res.json(filterFields(merged, fields)) })); // ── Hourly ──────────────────────────────────────────────────────────────────── app.get('/api/hourly', asyncHandler(async (req, res) => { const { fields } = req.query const coords = await getCoords() const now = new Date() now.setMinutes(0, 0, 0) const forecast24Limit = new Date(now.getTime() + 24 * 60 * 60_000) const start = req.query.start ? new Date(req.query.start) : now const end = req.query.end ? new Date(req.query.end) : forecast24Limit const [history, forecast24, meteo] = await Promise.all([ start < now ? queryHourly(start, now) : Promise.resolve([]), now < forecast24Limit && end > now ? getForecast().then(f => f.slots.filter(h => new Date(h.time) >= now && new Date(h.time) <= Math.min(end, forecast24Limit))) : Promise.resolve([]), end > forecast24Limit ? hourlyWeather(coords.latitude, coords.longitude, forecast24Limit, end) : Promise.resolve([]), ]) const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo]) res.json(filterArr(combined, fields)) })) // ── Daily ───────────────────────────────────────────────────────────────────── app.get('/api/daily', asyncHandler(async (req, res) => { const { fields } = req.query const coords = await getCoords() const now = new Date() const todayStart = new Date(now) todayStart.setHours(0, 0, 0, 0) const todayEnd = new Date(todayStart) todayEnd.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) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000) const [history, current, future] = await Promise.all([ start < todayStart ? queryDaily(start, todayStart) : Promise.resolve([]), start <= todayStart && end >= todayStart ? queryCurrent().then(resp => [resp]) : Promise.resolve([]), end > todayEnd ? dailyWeather(coords.latitude, coords.longitude, todayEnd, end) : Promise.resolve([]), ]) const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...current, ...future]) res.json(filterArr(daily, fields)) })) // ── Position ────────────────────────────────────────────────────────────────── app.get('/api/position', asyncHandler(async (req, res) => { const {fields} = req.query; const data = await getCoords(); res.json(filterFields(data, fields)); })); // ── Icon ────────────────────────────────────────────────────────────────────── app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => { const { icon } = req.params; if(!icon) return next(); // 404 fallback can handle missing icons res.contentType('image/png'); res.sendFile(await fetchIcon(icon)); })); // ── 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', asyncHandler(async (req, res) => res.json(await getADSB()))); app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getADSBHistory(req.params.icao)))); app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS()))); app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange()))); // ── DOCS ────────────────────────────────────────────────────────────────────── app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'})); app.get('/openapi.json',asyncHandler(async (req, res) => res.json(spec))); app.get('/openapi.yaml',asyncHandler(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 ───────────────────────────────────────────────────────────────────── const c = cfg(); setTimeout(getForecast, 1) setInterval(getForecast, forecastTTL) await initAircraftDb(); app.listen(c.PORT, () => { console.log(`⛅ Weather API — http://localhost:${c.PORT}`) });