Persist push subscriptions to disk, add background NWS alert poller

This commit is contained in:
Nate committed 2026-03-16 13:16:32 -04:00
1 parent 52c96225b9
commit bb1a8db0f9
2 files changed
+136 -1

No files matched your search

+128 -1
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs');
const webpush = require('web-push'); const webpush = require('web-push');
const app = express(); const app = express();
@@ -41,7 +42,47 @@ async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayM
throw lastErr || new Error('Unknown upstream fetch error'); throw lastErr || new Error('Unknown upstream fetch error');
} }
const subscriptions = new Map(); // --------------- 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);
}
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() { function getVapidConfig() {
const publicKey = process.env.VAPID_PUBLIC_KEY || ''; const publicKey = process.env.VAPID_PUBLIC_KEY || '';
@@ -277,6 +318,7 @@ app.post('/api/push/subscribe', (req, res) => {
location: location || null, location: location || null,
createdAt: Date.now(), createdAt: Date.now(),
}); });
saveSubscriptions();
res.json({ ok: true, count: subscriptions.size }); res.json({ ok: true, count: subscriptions.size });
}); });
@@ -284,6 +326,7 @@ app.post('/api/push/unsubscribe', (req, res) => {
const { endpoint } = req.body || {}; const { endpoint } = req.body || {};
if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' }); if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
subscriptions.delete(endpoint); subscriptions.delete(endpoint);
saveSubscriptions();
res.json({ ok: true, count: subscriptions.size }); res.json({ ok: true, count: subscriptions.size });
}); });
@@ -315,6 +358,7 @@ app.post('/api/push/test', async (req, res) => {
if (statusCode === 404 || statusCode === 410) { if (statusCode === 404 || statusCode === 410) {
subscriptions.delete(entry.subscription.endpoint); subscriptions.delete(entry.subscription.endpoint);
removed++; removed++;
saveSubscriptions();
} else { } else {
errors.push(String(e.message || e)); errors.push(String(e.message || e));
} }
@@ -324,6 +368,89 @@ app.post('/api/push/test', async (req, res) => {
res.json({ ok: true, sent, removed, errors, count: subscriptions.size }); 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 // RainViewer timestamps proxy
app.get('/api/radar/times', async (req, res) => { app.get('/api/radar/times', async (req, res) => {
try { try {
+8
View File
@@ -8,6 +8,14 @@ services:
environment: environment:
- PORT=3005 - PORT=3005
- NODE_ENV=production - NODE_ENV=production
# Required for push notifications — generate with: npx web-push generate-vapid-keys
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:nate@necloud.us}
# Optional: how often to poll NWS alerts (ms). Default: 300000 (5 min)
# - ALERT_POLL_INTERVAL_MS=300000
volumes:
- ./data:/app/data
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3005/"] test: ["CMD", "wget", "-qO-", "http://localhost:3005/"]
interval: 30s interval: 30s