372 lines
9.4 KiB
TypeScript
372 lines
9.4 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<ArrayBuffer> {
|
|
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 };
|