214 lines
9.4 KiB
JavaScript
214 lines
9.4 KiB
JavaScript
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, getAISImage} from './ais.mjs';
|
|
import {getADSBImage, 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-image/:icao', asyncHandler(async (req, res) => {
|
|
const blob = await getADSBImage(req.params.icao);
|
|
if (!blob) return res.status(404).send('No image found');
|
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
res.contentType(blob.type).send(buffer);
|
|
}));
|
|
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/ais-image/:mmsi', asyncHandler(async (req, res) => {
|
|
const blob = await getAISImage(req.params.mmsi);
|
|
if (!blob) return res.status(404).send('No image found');
|
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
res.contentType(blob.type).send(buffer);
|
|
}));
|
|
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}`)
|
|
});
|