- Fix InvalidCharacterError in push notifications with proper VAPID key validation - Add attractive PWA install prompt component with cross-browser support - Make notification settings always visible regardless of queue status - Implement PWA install manager with user engagement detection - Use SvelteKit navigation APIs instead of browser history API - Add comprehensive error handling and logging - Include cross-browser compatibility and responsive design - Add development tooling improvements Fixes push notification bugs and significantly improves PWA user experience with modern, accessible interface components and proper error handling.
379 lines
10 KiB
TypeScript
379 lines
10 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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 }; |