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:
219
src/lib/server/notifications/PushNotificationService.ts
Normal file
219
src/lib/server/notifications/PushNotificationService.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Push Notification Service for InstaRecipe Queue System
|
||||
*
|
||||
* Handles web push notifications for background processing updates
|
||||
* when users are not actively viewing the application.
|
||||
*/
|
||||
|
||||
import { queueConfig } from '../queue/config';
|
||||
|
||||
interface PushSubscription {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationPayload {
|
||||
title?: string;
|
||||
body: string;
|
||||
type: 'success' | 'error' | 'progress';
|
||||
itemId: string;
|
||||
recipeName?: string;
|
||||
tag?: string;
|
||||
requireInteraction?: boolean;
|
||||
analytics?: any;
|
||||
}
|
||||
|
||||
class PushNotificationService {
|
||||
private subscriptions = new Map<string, PushSubscription>();
|
||||
private vapidKeys: { publicKey: string; privateKey: string } | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadVapidKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load VAPID keys for push notifications
|
||||
* In production, these should be stored securely and loaded from environment
|
||||
*/
|
||||
private loadVapidKeys() {
|
||||
// Load from config module which uses SvelteKit's $env/dynamic/private
|
||||
this.vapidKeys = {
|
||||
publicKey: queueConfig.push.vapidPublicKey,
|
||||
privateKey: queueConfig.push.vapidPrivateKey
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public VAPID key for client-side subscription
|
||||
*/
|
||||
getPublicVapidKey(): string | null {
|
||||
return this.vapidKeys?.publicKey || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to push notifications
|
||||
*/
|
||||
async subscribe(clientId: string, subscription: PushSubscription): Promise<void> {
|
||||
console.log(`[PushService] Subscribing client ${clientId}`);
|
||||
this.subscriptions.set(clientId, subscription);
|
||||
|
||||
// In production, store subscriptions in database
|
||||
// For development, we'll keep them in memory
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a client from push notifications
|
||||
*/
|
||||
async unsubscribe(clientId: string): Promise<void> {
|
||||
console.log(`[PushService] Unsubscribing client ${clientId}`);
|
||||
this.subscriptions.delete(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to all subscribed clients
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
if (this.subscriptions.size === 0) {
|
||||
console.log('[PushService] No subscriptions, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
|
||||
console.log(`[PushService] Notification payload:`, payload);
|
||||
|
||||
// In a real implementation, this would use web-push library
|
||||
// For development/demo purposes, we'll simulate the notification
|
||||
const notificationData = {
|
||||
...payload,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
for (const [clientId, subscription] of this.subscriptions) {
|
||||
try {
|
||||
await this.sendToSubscription(subscription, notificationData);
|
||||
console.log(`[PushService] ✓ Sent notification to client ${clientId}`);
|
||||
} catch (error) {
|
||||
console.error(`[PushService] ✗ Failed to send to client ${clientId}:`, error);
|
||||
// Remove invalid subscriptions
|
||||
this.subscriptions.delete(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to specific subscription
|
||||
*/
|
||||
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
|
||||
// In production, use web-push library:
|
||||
// import webpush from 'web-push';
|
||||
//
|
||||
// webpush.setVapidDetails(
|
||||
// 'mailto:your-email@example.com',
|
||||
// this.vapidKeys.publicKey,
|
||||
// this.vapidKeys.privateKey
|
||||
// );
|
||||
//
|
||||
// return webpush.sendNotification(subscription, JSON.stringify(data));
|
||||
|
||||
// For development, we'll log the notification
|
||||
console.log(`[PushService] Would send push notification:`, {
|
||||
endpoint: subscription.endpoint,
|
||||
data: data
|
||||
});
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send success notification when recipe extraction completes
|
||||
*/
|
||||
async notifySuccess(itemId: string, recipeName?: string, tandoorUrl?: string): Promise<void> {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'success',
|
||||
itemId,
|
||||
recipeName,
|
||||
body: recipeName
|
||||
? `Recipe "${recipeName}" has been extracted and saved successfully!`
|
||||
: 'Your recipe extraction is complete and ready to view.',
|
||||
tag: `recipe-success-${itemId}`,
|
||||
requireInteraction: true,
|
||||
analytics: {
|
||||
event: 'recipe_extraction_complete',
|
||||
itemId,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
if (tandoorUrl) {
|
||||
payload.body += ' View it in Tandoor.';
|
||||
}
|
||||
|
||||
await this.sendNotification(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error notification when recipe extraction fails
|
||||
*/
|
||||
async notifyError(itemId: string, error: string): Promise<void> {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'error',
|
||||
itemId,
|
||||
body: `Recipe extraction failed: ${error}. Tap to retry.`,
|
||||
tag: `recipe-error-${itemId}`,
|
||||
requireInteraction: true,
|
||||
analytics: {
|
||||
event: 'recipe_extraction_failed',
|
||||
itemId,
|
||||
error,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
await this.sendNotification(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send progress notification for long-running extractions
|
||||
*/
|
||||
async notifyProgress(itemId: string, phase: string): Promise<void> {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'progress',
|
||||
itemId,
|
||||
body: `Recipe extraction in progress: ${phase}`,
|
||||
tag: `recipe-progress-${itemId}`,
|
||||
requireInteraction: false,
|
||||
analytics: {
|
||||
event: 'recipe_extraction_progress',
|
||||
itemId,
|
||||
phase,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
await this.sendNotification(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription count for monitoring
|
||||
*/
|
||||
getSubscriptionCount(): number {
|
||||
return this.subscriptions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all subscriptions (for testing/cleanup)
|
||||
*/
|
||||
clearAllSubscriptions(): void {
|
||||
console.log('[PushService] Clearing all subscriptions');
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const pushNotificationService = new PushNotificationService();
|
||||
|
||||
export type { PushSubscription, NotificationPayload };
|
||||
Reference in New Issue
Block a user