/** * Client-side Push Notification Manager * * Handles push notification subscription/unsubscription * and permission management in the browser. * * SSR-Safe: All browser API access is guarded and lazily initialized */ import { browser } from '$app/environment'; interface NotificationState { supported: boolean; permission: NotificationPermission; subscribed: boolean; loading: boolean; error: string | null; } class PushNotificationManager { private state: NotificationState = { supported: false, permission: 'default', subscribed: false, loading: false, error: null }; private listeners: Array<(state: NotificationState) => void> = []; private registration: ServiceWorkerRegistration | null = null; private _clientId: string | null = null; private _initialized = false; constructor() { // SSR-safe constructor: no browser API access // Initialization happens lazily when needed } /** * Lazy initialization - only runs in browser context */ private ensureInitialized(): void { if (this._initialized || !browser) return; this._initialized = true; this.checkSupport(); this.initializeServiceWorker(); } /** * Get clientId lazily - only generates in browser context */ private get clientId(): string { if (!this._clientId && browser) { this._clientId = this.generateClientId(); } return this._clientId || 'ssr-fallback'; } /** * Subscribe to state changes */ onStateChange(callback: (state: NotificationState) => void): () => void { this.ensureInitialized(); // Ensure initialized before sending state this.listeners.push(callback); callback(this.state); // Send initial state return () => { this.listeners = this.listeners.filter(cb => cb !== callback); }; } /** * Get current state */ getState(): NotificationState { this.ensureInitialized(); return { ...this.state }; } /** * Check if push notifications are supported * SSR-safe: guarded with browser check */ private checkSupport(): void { if (!browser) { this.state.supported = false; this.state.permission = 'denied'; return; } this.state.supported = ( 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window ); this.state.permission = this.state.supported ? Notification.permission : 'denied'; } /** * Initialize service worker registration * SSR-safe: guarded with browser and support checks */ private async initializeServiceWorker(): Promise { if (!browser || !this.state.supported) return; try { // Wait for service worker to be ready this.registration = await navigator.serviceWorker.ready; console.log('[PushManager] Service worker ready'); // Check if already subscribed const subscription = await this.registration.pushManager.getSubscription(); this.state.subscribed = !!subscription; this.notifyListeners(); } catch (error) { console.error('[PushManager] Service worker initialization failed:', error); this.state.error = 'Service worker not available'; this.notifyListeners(); } } /** * Request notification permission */ async requestPermission(): Promise { this.ensureInitialized(); if (!browser || !this.state.supported) { this.state.error = 'Push notifications not supported'; this.notifyListeners(); return false; } if (this.state.permission === 'granted') { return true; } try { this.state.loading = true; this.notifyListeners(); const permission = await Notification.requestPermission(); this.state.permission = permission; this.state.error = permission === 'denied' ? 'Permission denied' : null; this.state.loading = false; this.notifyListeners(); return permission === 'granted'; } catch (error) { console.error('[PushManager] Permission request failed:', error); this.state.error = 'Failed to request permission'; this.state.loading = false; this.notifyListeners(); return false; } } /** * Subscribe to push notifications */ async subscribe(): Promise { if (!await this.requestPermission()) { return false; } if (!this.registration) { this.state.error = 'Service worker not ready'; this.notifyListeners(); return false; } try { this.state.loading = true; this.state.error = null; this.notifyListeners(); // Get VAPID public key from server const vapidResponse = await fetch('/api/notifications/vapid-key'); if (!vapidResponse.ok) { throw new Error('Failed to get VAPID key'); } const { publicKey } = await vapidResponse.json(); // Create push subscription const subscription = await this.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(publicKey) }); // Send subscription to server const subscribeResponse = await fetch('/api/notifications/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subscription: subscription.toJSON(), clientId: this.clientId }) }); if (!subscribeResponse.ok) { throw new Error('Failed to register subscription with server'); } this.state.subscribed = true; this.state.loading = false; this.notifyListeners(); console.log('[PushManager] Successfully subscribed to push notifications'); return true; } catch (error) { console.error('[PushManager] Subscription failed:', error); this.state.error = 'Failed to subscribe to notifications'; this.state.loading = false; this.notifyListeners(); return false; } } /** * Unsubscribe from push notifications */ async unsubscribe(): Promise { if (!this.registration) { this.state.error = 'Service worker not ready'; this.notifyListeners(); return false; } try { this.state.loading = true; this.state.error = null; this.notifyListeners(); // Get current subscription const subscription = await this.registration.pushManager.getSubscription(); if (subscription) { // Unsubscribe from push service await subscription.unsubscribe(); // Remove from server await fetch('/api/notifications/subscribe', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: this.clientId }) }); } this.state.subscribed = false; this.state.loading = false; this.notifyListeners(); console.log('[PushManager] Successfully unsubscribed from push notifications'); return true; } catch (error) { console.error('[PushManager] Unsubscription failed:', error); this.state.error = 'Failed to unsubscribe from notifications'; this.state.loading = false; this.notifyListeners(); return false; } } /** * Toggle subscription state */ async toggleSubscription(): Promise { if (this.state.subscribed) { return await this.unsubscribe(); } else { return await this.subscribe(); } } /** * Generate unique client ID * SSR-safe: guarded with browser check, uses localStorage only in browser */ private generateClientId(): string { if (!browser) return ''; const stored = localStorage.getItem('push-client-id'); if (stored) return stored; const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; localStorage.setItem('push-client-id', id); return id; } /** * Convert URL-safe base64 string to Uint8Array * Enhanced with validation and error handling for VAPID keys * SSR-safe: uses window.atob only in browser context */ private urlBase64ToUint8Array(base64String: string): Uint8Array { if (!browser) { return new Uint8Array(0); } // Input validation if (!base64String || typeof base64String !== 'string') { console.error('[PushManager] Invalid VAPID key: empty or non-string'); return new Uint8Array(0); } // Remove whitespace and validate format const cleanKey = base64String.trim(); if (cleanKey.length === 0) { console.error('[PushManager] Invalid VAPID key: empty string'); return new Uint8Array(0); } // VAPID keys should be 65 characters (unpadded base64) if (cleanKey.length !== 65) { console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`); } try { // Add proper padding const padding = '='.repeat((4 - cleanKey.length % 4) % 4); const base64 = (cleanKey + padding) .replace(/-/g, '+') .replace(/_/g, '/'); // Validate base64 format before decoding const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/; if (!base64Regex.test(base64)) { throw new Error('Invalid base64 characters'); } const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`); return outputArray; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error('[PushManager] Failed to decode VAPID key:', error, 'Key:', cleanKey); throw new Error(`Invalid VAPID key format: ${errorMessage}`); } } /** * Notify all listeners of state change */ private notifyListeners(): void { this.listeners.forEach(callback => { try { callback({ ...this.state }); } catch (error) { console.error('[PushManager] Listener error:', error); } }); } } // Singleton instance export const pushNotificationManager = new PushNotificationManager(); export type { NotificationState };