import webpush from 'web-push'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import type { AppConfig } from './config.js'; import { pushSubs as dbPushSubs, type PushSubRow } from './db.js'; interface PushSubscriptionEntry { endpoint: string; deviceId?: string; subscription: { endpoint: string; keys: { p256dh: string; auth: string }; }; } // In-memory cache (populated from SQLite on init) let subscriptions: PushSubscriptionEntry[] = []; let cachedVapidPublicKey: string | null = null; // Pending session notification counts (in-memory, resets on restart) const pendingSessions = new Map(); // sessionId -> count export function initPush(config: AppConfig): void { mkdirSync(config.clawtapDir, { recursive: true }); const vapidPath = config.paths.vapidKeys; // Load or generate VAPID keys let vapidKeys: { publicKey: string; privateKey: string }; if (existsSync(vapidPath)) { vapidKeys = JSON.parse(readFileSync(vapidPath, 'utf-8')); } else { vapidKeys = webpush.generateVAPIDKeys(); writeFileSync(vapidPath, JSON.stringify(vapidKeys, null, 2)); console.log('[push] Generated new VAPID keys'); } const email = process.env.VAPID_EMAIL || 'izackp@gmail.com'; cachedVapidPublicKey = vapidKeys.publicKey; webpush.setVapidDetails(`mailto:${email}`, vapidKeys.publicKey, vapidKeys.privateKey); // Load subscriptions from SQLite into in-memory cache const rows = dbPushSubs.getAll(); subscriptions = rows.map(row => ({ endpoint: row.endpoint, deviceId: row.device_id ?? undefined, subscription: { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth }, }, })); console.log(`[push] Initialized with ${subscriptions.length} subscription(s)`); } export function getVapidPublicKey(): string | null { return cachedVapidPublicKey; } export function saveSubscription(subscription: PushSubscriptionEntry['subscription'], deviceId?: string): void { // Save to SQLite — removes old endpoints for same device_id dbPushSubs.save(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, deviceId ?? null); // Update in-memory cache: remove old entries for same device, add new if (deviceId) { subscriptions = subscriptions.filter(s => s.endpoint === subscription.endpoint || !s.deviceId || s.deviceId !== deviceId); } else { subscriptions = subscriptions.filter(s => s.endpoint !== subscription.endpoint); } subscriptions.push({ endpoint: subscription.endpoint, subscription, deviceId }); } export function removeSubscription(endpoint: string): void { // Remove from SQLite dbPushSubs.remove(endpoint); // Update in-memory cache subscriptions = subscriptions.filter(s => s.endpoint !== endpoint); } export function incrementPending(sessionId: string): number { const count = (pendingSessions.get(sessionId) || 0) + 1; pendingSessions.set(sessionId, count); return _totalPending(); } export function clearPending(sessionId: string): number { pendingSessions.delete(sessionId); return _totalPending(); } export function getPendingSessions(): Record { const result: Record = {}; for (const [sid, count] of pendingSessions) { result[sid] = count; } return result; } export async function sendPush(payload: unknown): Promise { if (subscriptions.length === 0) { console.log('[push] sendPush: no subscriptions registered, skipping'); return; } console.log(`[push] sendPush: sending to ${subscriptions.length} subscription(s)`); const body = JSON.stringify(payload); const expired: string[] = []; let errorCount = 0; await Promise.allSettled( subscriptions.map(async ({ endpoint, subscription }) => { try { await webpush.sendNotification(subscription, body); } catch (err) { const e = err as { statusCode?: number; message?: string; body?: unknown }; const bodyReason = (() => { try { return JSON.parse(e.body as string)?.reason; } catch { return null; } })(); if (e.statusCode === 410 || e.statusCode === 404 || bodyReason === 'BadJwtToken') { expired.push(endpoint); } else { errorCount++; console.error(`[push] Failed to send to ${endpoint.slice(0, 50)}: status=${e.statusCode} msg=${e.message} body=${JSON.stringify(e.body)}`); } } }) ); const ok = subscriptions.length - expired.length - errorCount; console.log(`[push] sendPush: done (${ok} ok, ${expired.length} expired, ${errorCount} failed)`); // Clean up expired subscriptions if (expired.length > 0) { for (const ep of expired) { dbPushSubs.remove(ep); } subscriptions = subscriptions.filter(s => !expired.includes(s.endpoint)); console.log(`[push] Removed ${expired.length} expired subscription(s)`); } } function _totalPending(): number { let total = 0; for (const count of pendingSessions.values()) total += count; return total; }