Files
clawtap/server/push.ts
T

143 lines
4.9 KiB
TypeScript

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<string, number>(); // 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<string, number> {
const result: Record<string, number> = {};
for (const [sid, count] of pendingSessions) {
result[sid] = count;
}
return result;
}
export async function sendPush(payload: unknown): Promise<void> {
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;
}