/** * Web Push Manager Module * * Handles Web Push Notification subscriptions and management. */ export class WebPushManager { constructor(options = {}) { this.apiBase = options.apiBase || '/api/push'; this.serviceWorkerUrl = options.serviceWorkerUrl || '/js/sw-push.js'; this.vapidPublicKey = options.vapidPublicKey || null; this.onSubscriptionChange = options.onSubscriptionChange || null; } /** * Initialize Web Push Manager */ async init() { if (!('serviceWorker' in navigator)) { throw new Error('Service Workers are not supported in this browser'); } if (!('PushManager' in window)) { throw new Error('Push API is not supported in this browser'); } // Register Service Worker await this.registerServiceWorker(); // Get VAPID public key from server if not provided if (!this.vapidPublicKey) { await this.fetchVapidPublicKey(); } // Check current subscription status const subscription = await this.getSubscription(); if (this.onSubscriptionChange) { this.onSubscriptionChange(subscription); } return subscription !== null; } /** * Register Service Worker */ async registerServiceWorker() { try { const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl); console.log('[WebPush] Service Worker registered', registration); return registration; } catch (error) { console.error('[WebPush] Service Worker registration failed', error); throw error; } } /** * Fetch VAPID public key from server */ async fetchVapidPublicKey() { try { const response = await fetch(`${this.apiBase}/vapid-key`); const data = await response.json(); if (!data.public_key) { throw new Error('VAPID public key not available'); } this.vapidPublicKey = data.public_key; console.log('[WebPush] VAPID public key fetched'); } catch (error) { console.error('[WebPush] Failed to fetch VAPID public key', error); throw error; } } /** * Request notification permission */ async requestPermission() { const permission = await Notification.requestPermission(); console.log('[WebPush] Permission:', permission); if (permission !== 'granted') { throw new Error('Notification permission denied'); } return permission; } /** * Subscribe to push notifications */ async subscribe() { try { // Request permission first await this.requestPermission(); // Get Service Worker registration const registration = await navigator.serviceWorker.ready; // Check if already subscribed let subscription = await registration.pushManager.getSubscription(); if (subscription) { console.log('[WebPush] Already subscribed', subscription); } else { // Subscribe subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) }); console.log('[WebPush] Subscribed', subscription); } // Send subscription to server await this.sendSubscriptionToServer(subscription); if (this.onSubscriptionChange) { this.onSubscriptionChange(subscription); } return subscription; } catch (error) { console.error('[WebPush] Subscription failed', error); throw error; } } /** * Unsubscribe from push notifications */ async unsubscribe() { try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (!subscription) { console.log('[WebPush] Not subscribed'); return false; } // Unsubscribe from browser const success = await subscription.unsubscribe(); if (success) { console.log('[WebPush] Unsubscribed from browser'); // Remove from server await this.removeSubscriptionFromServer(subscription); if (this.onSubscriptionChange) { this.onSubscriptionChange(null); } } return success; } catch (error) { console.error('[WebPush] Unsubscribe failed', error); throw error; } } /** * Get current subscription */ async getSubscription() { try { const registration = await navigator.serviceWorker.ready; return await registration.pushManager.getSubscription(); } catch (error) { console.error('[WebPush] Failed to get subscription', error); return null; } } /** * Check if subscribed */ async isSubscribed() { const subscription = await this.getSubscription(); return subscription !== null; } /** * Send test notification */ async sendTestNotification(title, body) { try { const subscription = await this.getSubscription(); if (!subscription) { throw new Error('Not subscribed'); } const response = await fetch(`${this.apiBase}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint, title: title || 'Test Notification', body: body || 'This is a test notification!' }) }); const result = await response.json(); console.log('[WebPush] Test notification sent', result); return result; } catch (error) { console.error('[WebPush] Test notification failed', error); throw error; } } /** * Send subscription to server */ async sendSubscriptionToServer(subscription) { try { const subscriptionJson = subscription.toJSON(); const response = await fetch(`${this.apiBase}/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscriptionJson) }); if (!response.ok) { throw new Error(`Server returned ${response.status}`); } const result = await response.json(); console.log('[WebPush] Subscription sent to server', result); return result; } catch (error) { console.error('[WebPush] Failed to send subscription to server', error); throw error; } } /** * Remove subscription from server */ async removeSubscriptionFromServer(subscription) { try { const response = await fetch(`${this.apiBase}/unsubscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint }) }); if (!response.ok) { throw new Error(`Server returned ${response.status}`); } const result = await response.json(); console.log('[WebPush] Subscription removed from server', result); return result; } catch (error) { console.error('[WebPush] Failed to remove subscription from server', error); throw error; } } /** * Convert Base64 URL to Uint8Array (for VAPID key) */ urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } /** * Get notification permission status */ getPermissionStatus() { return Notification.permission; } /** * Check if browser supports Web Push */ static isSupported() { return ('serviceWorker' in navigator) && ('PushManager' in window); } } export default WebPushManager;