fix(ssr): resolve EventSource SSR violations and implement best practices
- Fix EventSource is not defined error in queue dashboard - Add browser guards for all EventSource usage - Replace static constants (EventSource.OPEN/CLOSED) with numeric values - Fix setInterval SSR violation in LLM health indicator - Replace $effect anti-pattern with onMount in share page - Add comprehensive SvelteKit SSR best practices documentation - Add SSR audit and testing verification All changes follow SvelteKit best practices and are verified against official documentation. Production build succeeds with no SSR errors. Closes: FixEventSourceSSR See: docs/outcomes/FixEventSourceSSR.md
This commit is contained in:
344
src/lib/client/PushNotificationManager.ts
Normal file
344
src/lib/client/PushNotificationManager.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 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 VAPID key to Uint8Array
|
||||
* SSR-safe: uses window.atob only in browser context
|
||||
*/
|
||||
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
if (!browser) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
199
src/lib/client/ServiceWorkerMessageHandler.ts
Normal file
199
src/lib/client/ServiceWorkerMessageHandler.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Service Worker Message Handler
|
||||
*
|
||||
* Handles messages from service worker (like notification actions)
|
||||
* and coordinates with the main application.
|
||||
*/
|
||||
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
action?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
class ServiceWorkerMessageHandler {
|
||||
private retryCallbacks = new Map<string, () => void>();
|
||||
|
||||
constructor() {
|
||||
this.initializeMessageListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for messages from service worker
|
||||
*/
|
||||
private initializeMessageListener(): void {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
this.handleMessage(event.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from service worker
|
||||
*/
|
||||
private handleMessage(message: ServiceWorkerMessage): void {
|
||||
console.log('[SW-Handler] Message received:', message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'notification-action':
|
||||
this.handleNotificationAction(message.action, message.data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[SW-Handler] Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notification action clicks
|
||||
*/
|
||||
private handleNotificationAction(action: string | undefined, data: any): void {
|
||||
if (!action || !data?.itemId) {
|
||||
console.warn('[SW-Handler] Invalid notification action:', { action, data });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'view':
|
||||
this.handleViewAction(data.itemId);
|
||||
break;
|
||||
|
||||
case 'retry':
|
||||
this.handleRetryAction(data.itemId);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[SW-Handler] Unknown notification action:', action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "view" action - scroll to item and highlight
|
||||
*/
|
||||
private handleViewAction(itemId: string): void {
|
||||
console.log('[SW-Handler] View action for item:', itemId);
|
||||
|
||||
// Find the queue item card and scroll to it
|
||||
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
|
||||
// Add temporary highlight effect
|
||||
element.classList.add('ring-2', 'ring-blue-500');
|
||||
setTimeout(() => {
|
||||
element.classList.remove('ring-2', 'ring-blue-500');
|
||||
}, 3000);
|
||||
} else {
|
||||
// If not found, navigate to homepage with highlight
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('highlight', itemId);
|
||||
window.history.pushState({}, '', url.toString());
|
||||
|
||||
// Refresh page to show the item
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "retry" action - trigger retry for failed item
|
||||
*/
|
||||
private async handleRetryAction(itemId: string): Promise<void> {
|
||||
console.log('[SW-Handler] Retry action for item:', itemId);
|
||||
|
||||
// Check if there's a registered callback
|
||||
const callback = this.retryCallbacks.get(itemId);
|
||||
if (callback) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: direct API call
|
||||
try {
|
||||
const response = await fetch(`/api/queue/${itemId}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[SW-Handler] Retry initiated via API');
|
||||
|
||||
// Show user feedback
|
||||
this.showRetryFeedback(true);
|
||||
} else {
|
||||
throw new Error('Retry request failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SW-Handler] Retry failed:', error);
|
||||
this.showRetryFeedback(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register retry callback for a queue item
|
||||
*/
|
||||
registerRetryCallback(itemId: string, callback: () => void): void {
|
||||
this.retryCallbacks.set(itemId, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister retry callback
|
||||
*/
|
||||
unregisterRetryCallback(itemId: string): void {
|
||||
this.retryCallbacks.delete(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show retry feedback to user
|
||||
*/
|
||||
private showRetryFeedback(success: boolean): void {
|
||||
// Create temporary toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
|
||||
success ? 'bg-green-600' : 'bg-red-600'
|
||||
}`;
|
||||
toast.textContent = success
|
||||
? 'Retry initiated - check the queue for updates'
|
||||
: 'Failed to retry - please try again manually';
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to service worker
|
||||
*/
|
||||
async sendMessageToSW(message: any): Promise<any> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service worker not supported');
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!registration.active) {
|
||||
throw new Error('Service worker not active');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = (event) => {
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
registration.active?.postMessage(message, [channel.port2]);
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
reject(new Error('Service worker message timeout'));
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const serviceWorkerMessageHandler = new ServiceWorkerMessageHandler();
|
||||
Reference in New Issue
Block a user