Files

593 lines
44 KiB
Diff

diff --git a/.tmp/imgdiff/old/server.js b/.tmp/imgdiff/new/server.js
index 177207e..2aa7df2 100644
--- a/.tmp/imgdiff/old/server.js
+++ b/.tmp/imgdiff/new/server.js
@@ -1,140 +1,75 @@
const express = require('express');
const path = require('path');
-const fs = require('fs');
-const webpush = require('web-push');
const app = express();
const PORT = process.env.PORT || 3000;
+const weatherCache = new Map();
-app.use(express.json({ limit: '256kb' }));
+app.use(express.static(path.join(__dirname, 'public')));
-// Lightweight request logging for observability.
-app.use((req, res, next) => {
- const started = Date.now();
- res.on('finish', () => {
- const ms = Date.now() - started;
- console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
- });
- next();
+// Prevent browser favicon requests from showing noisy 404s.
+app.get('/favicon.ico', (_req, res) => {
+ res.status(204).end();
});
-function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
+// Geocoding proxy
+app.get('/api/geocode', async (req, res) => {
+ const { q } = req.query;
+ if (!q) return res.status(400).json({ error: 'Missing query' });
+ try {
+ const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
-async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayMs = 300) {
- let lastErr = null;
- for (let i = 0; i < attempts; i++) {
- try {
- const r = await fetch(url, options);
- if (!r.ok) {
- const text = await r.text().catch(() => '');
- throw new Error(`Upstream status ${r.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
- }
- return await r.json();
- } catch (e) {
- lastErr = e;
- if (i < attempts - 1) {
- await sleep(initialDelayMs * Math.pow(2, i));
+ let openMeteoData = null;
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 9000);
+ try {
+ const r = await fetch(url, { signal: controller.signal });
+ clearTimeout(timeoutId);
+ if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`);
+ openMeteoData = await r.json();
+ break;
+ } catch (err) {
+ clearTimeout(timeoutId);
+ if (attempt === 2) {
+ console.warn('Open-Meteo geocode failed after retries:', err.message);
+ } else {
+ await new Promise(resolve => setTimeout(resolve, 300 * attempt));
+ }
}
}
- }
- throw lastErr || new Error('Unknown upstream fetch error');
-}
-
-// --------------- Persistent data storage ---------------
-const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
-const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
-const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json');
-
-try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
-function loadSubscriptions() {
- try {
- const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
- const map = new Map();
- for (const entry of arr) {
- if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry);
+ if (openMeteoData?.results?.length) {
+ return res.json(openMeteoData);
}
- return map;
- } catch (_) { return new Map(); }
-}
-
-function saveSubscriptions() {
- try {
- fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2));
- } catch (e) { console.error('Failed to save subscriptions:', e.message); }
-}
-
-const subscriptions = loadSubscriptions();
-if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`);
-function loadSentAlertIds() {
- try {
- return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8')));
- } catch (_) { return new Map(); }
-}
-
-function saveSentAlertIds() {
- try {
- fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2));
- } catch (e) { console.error('Failed to save sent alert IDs:', e.message); }
-}
-
-const sentAlertIds = loadSentAlertIds();
-// --------------------------------------------------------
-
-function getVapidConfig() {
- const publicKey = process.env.VAPID_PUBLIC_KEY || '';
- const privateKey = process.env.VAPID_PRIVATE_KEY || '';
- const subject = process.env.VAPID_SUBJECT || 'mailto:nate@necloud.us';
- return { publicKey, privateKey, subject };
-}
-
-function pushConfigured() {
- const cfg = getVapidConfig();
- return Boolean(cfg.publicKey && cfg.privateKey);
-}
-
-function ensurePushConfigured() {
- const cfg = getVapidConfig();
- if (!cfg.publicKey || !cfg.privateKey) {
- return false;
- }
- webpush.setVapidDetails(cfg.subject, cfg.publicKey, cfg.privateKey);
- return true;
-}
-
-// HTML: never cache (always revalidate so SW/app updates are picked up immediately)
-app.use('/', (req, res, next) => {
- if (req.path === '/' || req.path.endsWith('.html')) {
- res.setHeader('Cache-Control', 'no-store');
- } else if (req.path.endsWith('.css') || req.path.endsWith('.js') || req.path === '/sw.js' || req.path === '/manifest.json') {
- // Short-lived cache ΓÇö browser revalidates every 60 s but still gets a 304 fast
- res.setHeader('Cache-Control', 'no-cache');
- }
- next();
-});
-app.use(express.static(path.join(__dirname, 'public')));
+ // Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable.
+ const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' });
+ const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/json',
+ },
+ });
-app.get('/healthz', (_req, res) => {
- res.json({
- ok: true,
- uptimeSec: Math.round(process.uptime()),
- pushConfigured: pushConfigured(),
- subscriptionCount: subscriptions.size,
- now: new Date().toISOString(),
- });
-});
+ if (!nom.ok) {
+ throw new Error(`Nominatim geocode status ${nom.status}`);
+ }
-// Geocoding proxy
-app.get('/api/geocode', async (req, res) => {
- const { q } = req.query;
- if (!q) return res.status(400).json({ error: 'Missing query' });
- try {
- const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
- const data = await fetchJsonWithRetry(url);
- res.json(data);
+ const nomData = await nom.json();
+ const results = (nomData || []).map(item => {
+ const address = item.address || {};
+ return {
+ name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown',
+ latitude: Number(item.lat),
+ longitude: Number(item.lon),
+ country: address.country || '',
+ admin1: address.state || address.region || address.county || '',
+ timezone: 'auto',
+ };
+ }).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude));
+
+ return res.json({ results });
} catch (e) {
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
}
@@ -146,31 +81,6 @@ app.get('/api/reverse-geocode', async (req, res) => {
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
- // Prefer Open-Meteo reverse geocoding for stable city/region labels.
- const omParams = new URLSearchParams({
- latitude: String(lat),
- longitude: String(lon),
- count: '1',
- language: 'en',
- format: 'json',
- });
-
- const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null);
- if (omData) {
- const top = omData?.results?.[0];
- if (top) {
- return res.json({
- name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location',
- admin1: top.admin1 || top.admin2 || '',
- country: top.country || '',
- latitude: Number(lat),
- longitude: Number(lon),
- timezone: top.timezone || 'auto',
- display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '),
- });
- }
- }
-
const params = new URLSearchParams({
format: 'jsonv2',
lat: String(lat),
@@ -179,13 +89,18 @@ app.get('/api/reverse-geocode', async (req, res) => {
addressdetails: '1',
});
- const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
+ const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/json',
},
});
+ if (!r.ok) {
+ throw new Error(`Reverse geocoding failed with status ${r.status}`);
+ }
+
+ const data = await r.json();
const address = data.address || {};
const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
@@ -209,13 +124,14 @@ app.get('/api/weather', async (req, res) => {
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
const timezone = tz || 'auto';
const useMetric = units === 'metric';
+ const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
try {
const params = new URLSearchParams({
latitude: lat, longitude: lon, timezone,
current: [
'temperature_2m','relative_humidity_2m','apparent_temperature',
'is_day','precipitation','weather_code','cloud_cover',
- 'wind_speed_10m','wind_direction_10m','surface_pressure','visibility'
+ 'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
].join(','),
hourly: [
'temperature_2m','precipitation_probability','weather_code',
@@ -231,9 +147,44 @@ app.get('/api/weather', async (req, res) => {
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
precipitation_unit: useMetric ? 'mm' : 'inch'
});
- const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`);
- res.json(data);
+
+ let lastError = null;
+ const upstreamTimeoutMs = 12000;
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
+ try {
+ const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal });
+ clearTimeout(timeoutId);
+ const body = await r.text();
+
+ if (!r.ok) {
+ throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`);
+ }
+
+ const data = JSON.parse(body);
+ weatherCache.set(cacheKey, { data, at: Date.now() });
+ return res.json(data);
+ } catch (err) {
+ clearTimeout(timeoutId);
+ lastError = err;
+ const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error');
+ console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`);
+ if (attempt < 3) {
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
+ }
+ }
+ }
+
+ throw lastError || new Error('Weather upstream failed');
} catch (e) {
+ const cached = weatherCache.get(cacheKey);
+ if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
+ res.setHeader('X-Weather-Cache', 'stale-fallback');
+ console.warn('Weather proxy using stale cache for', cacheKey);
+ return res.json(cached.data);
+ }
+ console.error('Weather proxy failed:', e.message);
res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
}
});
@@ -250,10 +201,34 @@ app.get('/api/air-quality', async (req, res) => {
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
forecast_days: 1,
});
- const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
- res.json(data);
+ let lastError = null;
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
+ try {
+ const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, {
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ if (!r.ok) {
+ throw new Error(`Open-Meteo status ${r.status}`);
+ }
+ const data = await r.json();
+ return res.json(data);
+ } catch (err) {
+ clearTimeout(timeoutId);
+ lastError = err;
+ if (attempt < 2) {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ }
+ }
+
+ throw lastError || new Error('Air quality upstream failed');
} catch (e) {
- res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
+ console.error('Air quality fetch error:', e);
+ res.setHeader('X-Air-Quality-Fallback', 'empty');
+ res.json({ current: {} });
}
});
@@ -262,211 +237,48 @@ app.get('/api/alerts', async (req, res) => {
const { lat, lon } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
- const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
+
+ const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/geo+json',
},
+ signal: controller.signal,
});
+
+ clearTimeout(timeoutId);
+
+ if (!r.ok) {
+ throw new Error(`NWS API returned ${r.status}`);
+ }
+ const data = await r.json();
res.json(data);
} catch (e) {
- res.status(500).json({ error: 'Alerts fetch failed', detail: e.message });
- }
-});
-
-// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries).
-app.get('/api/lightning', async (req, res) => {
- const { lat, lon, age } = req.query;
- if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
- const ageMinutes = Number(age || 15);
-
- try {
- const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
- headers: {
- 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
- Accept: 'application/geo+json',
- },
- });
-
- const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000;
- const features = (data?.features || []).filter(f => {
- const p = f?.properties || {};
- const event = String(p.event || '').toLowerCase();
- const relevant = event.includes('thunderstorm') || event.includes('lightning');
- if (!relevant) return false;
- const ts = Date.parse(p.sent || p.onset || p.effective || '');
- if (Number.isNaN(ts)) return true;
- return ts >= cutoff;
- });
-
- res.json({
- source: 'weather.gov alerts',
- count: features.length,
- features,
- ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15,
- });
- } catch (e) {
- res.status(500).json({ error: 'Lightning fetch failed', detail: e.message });
- }
-});
-
-app.get('/api/push/public-key', (_req, res) => {
- const { publicKey } = getVapidConfig();
- res.json({
- enabled: pushConfigured(),
- publicKey: publicKey || null,
- });
-});
-
-app.post('/api/push/subscribe', (req, res) => {
- const { subscription, location } = req.body || {};
- if (!subscription?.endpoint) {
- return res.status(400).json({ error: 'Missing subscription endpoint' });
- }
- subscriptions.set(subscription.endpoint, {
- subscription,
- location: location || null,
- createdAt: Date.now(),
- });
- saveSubscriptions();
- res.json({ ok: true, count: subscriptions.size });
-});
-
-app.post('/api/push/unsubscribe', (req, res) => {
- const { endpoint } = req.body || {};
- if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
- subscriptions.delete(endpoint);
- saveSubscriptions();
- res.json({ ok: true, count: subscriptions.size });
-});
-
-app.post('/api/push/test', async (req, res) => {
- if (!ensurePushConfigured()) {
- return res.status(400).json({
- error: 'Push is not configured. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.',
- });
- }
-
- const payload = JSON.stringify({
- title: req.body?.title || 'Weather test notification',
- body: req.body?.body || 'Push notifications are working.',
- url: req.body?.url || '/',
- ts: Date.now(),
- });
-
- let sent = 0;
- let removed = 0;
- const errors = [];
-
- const entries = Array.from(subscriptions.values());
- for (const entry of entries) {
- try {
- await webpush.sendNotification(entry.subscription, payload);
- sent++;
- } catch (e) {
- const statusCode = e?.statusCode;
- if (statusCode === 404 || statusCode === 410) {
- subscriptions.delete(entry.subscription.endpoint);
- removed++;
- saveSubscriptions();
- } else {
- errors.push(String(e.message || e));
- }
- }
+ console.error('Alerts fetch error:', e.message);
+ res.setHeader('X-Alerts-Fallback', 'empty');
+ res.json({ features: [] });
}
-
- res.json({ ok: true, sent, removed, errors, count: subscriptions.size });
});
-// --------------- Background NWS alert poller ---------------
-// Runs every ALERT_POLL_INTERVAL_MS (default 5 min).
-// Sends push notifications to all subscribers for any new active NWS alerts
-// at their saved location. Deduplicates by alert ID (persisted to disk).
-
-function cleanSentAlertIds() {
- const cutoff = Date.now() - 48 * 60 * 60 * 1000;
- for (const [id, ts] of sentAlertIds) {
- if (ts < cutoff) sentAlertIds.delete(id);
- }
-}
-
-async function alertPoller() {
- if (!pushConfigured() || !ensurePushConfigured()) return;
- if (subscriptions.size === 0) return;
-
- cleanSentAlertIds();
-
- // Group subscribers by rounded lat/lon so we don't spam the NWS API.
- const byLocation = new Map();
- for (const entry of subscriptions.values()) {
- const loc = entry.location;
- if (!loc?.latitude || !loc?.longitude) continue;
- const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`;
- if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] });
- byLocation.get(key).entries.push(entry);
- }
-
- let savedIds = false;
- for (const { loc, entries } of byLocation.values()) {
- try {
- const data = await fetchJsonWithRetry(
- `https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`,
- { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } }
- ).catch(() => null);
- if (!data?.features?.length) continue;
-
- for (const feature of data.features) {
- const id = feature?.properties?.id;
- if (!id || sentAlertIds.has(id)) continue;
-
- const props = feature.properties || {};
- const title = props.event || 'Weather Alert';
- const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area');
- const headline = props.headline || props.description?.slice(0, 120) || title;
- const payload = JSON.stringify({
- title,
- body: `${area}: ${headline}`,
- url: '/',
- ts: Date.now(),
- });
-
- let anySuccess = false;
- for (const entry of entries) {
- try {
- await webpush.sendNotification(entry.subscription, payload);
- anySuccess = true;
- } catch (e) {
- if (e?.statusCode === 404 || e?.statusCode === 410) {
- subscriptions.delete(entry.subscription.endpoint);
- saveSubscriptions();
- }
- }
- }
-
- if (anySuccess) {
- console.log(`Alert push sent: [${id}] ${title} → ${area}`);
- sentAlertIds.set(id, Date.now());
- savedIds = true;
- }
- }
- } catch (e) {
- console.error('Alert poller error:', e.message);
- }
- }
-
- if (savedIds) saveSentAlertIds();
-}
-
-const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000;
-setInterval(alertPoller, POLL_INTERVAL_MS);
-// -----------------------------------------------------------
-
// RainViewer timestamps proxy
app.get('/api/radar/times', async (req, res) => {
try {
- const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json');
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 25000);
+
+ const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', {
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ if (!r.ok) {
+ throw new Error(`RainViewer API returned ${r.status}`);
+ }
+ const data = await r.json();
res.json(data);
} catch (e) {
+ console.error('Radar times fetch error:', e.message);
res.status(500).json({ error: 'Radar times failed', detail: e.message });
}
});