simplify
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* PWA Installation Manager
|
||||
*
|
||||
*
|
||||
* Handles PWA installation flow with cross-browser support.
|
||||
* Provides beforeinstallprompt event handling, user engagement detection,
|
||||
* and dismissal state management for the install prompt.
|
||||
@@ -9,193 +9,193 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
export class PWAInstallManager {
|
||||
private deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
private listeners: Array<(canInstall: boolean) => void> = [];
|
||||
private installable = false;
|
||||
private deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
private listeners: Array<(canInstall: boolean) => void> = [];
|
||||
private installable = false;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.initializeInstallPrompt();
|
||||
}
|
||||
}
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.initializeInstallPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PWA install prompt event listeners
|
||||
*/
|
||||
private initializeInstallPrompt(): void {
|
||||
// Listen for beforeinstallprompt event (Chrome, Edge)
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
e.preventDefault();
|
||||
this.deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
this.installable = true;
|
||||
this.notifyListeners(true);
|
||||
console.log('[PWA] Install prompt available');
|
||||
});
|
||||
/**
|
||||
* Initialize PWA install prompt event listeners
|
||||
*/
|
||||
private initializeInstallPrompt(): void {
|
||||
// Listen for beforeinstallprompt event (Chrome, Edge)
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
e.preventDefault();
|
||||
this.deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
this.installable = true;
|
||||
this.notifyListeners(true);
|
||||
console.log('[PWA] Install prompt available');
|
||||
});
|
||||
|
||||
// Listen for app installation completion
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('[PWA] App was installed');
|
||||
this.installable = false;
|
||||
this.deferredPrompt = null;
|
||||
this.notifyListeners(false);
|
||||
|
||||
// Clear dismissal state since user installed
|
||||
this.clearDismissed();
|
||||
});
|
||||
// Listen for app installation completion
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('[PWA] App was installed');
|
||||
this.installable = false;
|
||||
this.deferredPrompt = null;
|
||||
this.notifyListeners(false);
|
||||
|
||||
// Check if already installed
|
||||
if (this.isStandalone()) {
|
||||
console.log('[PWA] App is already running in standalone mode');
|
||||
}
|
||||
}
|
||||
// Clear dismissal state since user installed
|
||||
this.clearDismissed();
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if PWA can be installed
|
||||
*/
|
||||
public canInstall(): boolean {
|
||||
return this.installable && this.deferredPrompt !== null;
|
||||
}
|
||||
// Check if already installed
|
||||
if (this.isStandalone()) {
|
||||
console.log('[PWA] App is already running in standalone mode');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the browser's install prompt
|
||||
*
|
||||
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
|
||||
*/
|
||||
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
|
||||
if (!this.deferredPrompt) {
|
||||
console.warn('[PWA] Install prompt not available');
|
||||
return 'unavailable';
|
||||
}
|
||||
/**
|
||||
* Check if PWA can be installed
|
||||
*/
|
||||
public canInstall(): boolean {
|
||||
return this.installable && this.deferredPrompt !== null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deferredPrompt.prompt();
|
||||
const { outcome } = await this.deferredPrompt.userChoice;
|
||||
|
||||
this.deferredPrompt = null;
|
||||
this.installable = false;
|
||||
this.notifyListeners(false);
|
||||
|
||||
console.log(`[PWA] Install prompt ${outcome}`);
|
||||
return outcome;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Install prompt failed:', error);
|
||||
return 'dismissed';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Show the browser's install prompt
|
||||
*
|
||||
* @returns Promise resolving to user's choice or 'unavailable' if no prompt available
|
||||
*/
|
||||
public async showInstallPrompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
|
||||
if (!this.deferredPrompt) {
|
||||
console.warn('[PWA] Install prompt not available');
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for install state changes
|
||||
*
|
||||
* @param callback Function to call when install state changes
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
|
||||
this.listeners.push(callback);
|
||||
|
||||
// Call immediately with current state
|
||||
callback(this.canInstall());
|
||||
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.deferredPrompt.prompt();
|
||||
const { outcome } = await this.deferredPrompt.userChoice;
|
||||
|
||||
/**
|
||||
* Notify all listeners of state change
|
||||
*/
|
||||
private notifyListeners(canInstall: boolean): void {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(canInstall);
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error in install state listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.deferredPrompt = null;
|
||||
this.installable = false;
|
||||
this.notifyListeners(false);
|
||||
|
||||
/**
|
||||
* Check if app is running in standalone mode (already installed)
|
||||
*/
|
||||
public isStandalone(): boolean {
|
||||
if (!browser) return false;
|
||||
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true ||
|
||||
document.referrer.includes('android-app://')
|
||||
);
|
||||
}
|
||||
console.log(`[PWA] Install prompt ${outcome}`);
|
||||
return outcome;
|
||||
} catch (error) {
|
||||
console.error('[PWA] Install prompt failed:', error);
|
||||
return 'dismissed';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has dismissed the install prompt
|
||||
*/
|
||||
public isDismissed(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem('pwa-install-dismissed') === 'true';
|
||||
}
|
||||
/**
|
||||
* Register a callback for install state changes
|
||||
*
|
||||
* @param callback Function to call when install state changes
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
public onInstallStateChange(callback: (canInstall: boolean) => void): () => void {
|
||||
this.listeners.push(callback);
|
||||
|
||||
/**
|
||||
* Mark install prompt as dismissed by user
|
||||
*/
|
||||
public setDismissed(): void {
|
||||
if (browser) {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
console.log('[PWA] Install prompt dismissed by user');
|
||||
}
|
||||
}
|
||||
// Call immediately with current state
|
||||
callback(this.canInstall());
|
||||
|
||||
/**
|
||||
* Clear dismissal state (called when app is installed)
|
||||
*/
|
||||
public clearDismissed(): void {
|
||||
if (browser) {
|
||||
localStorage.removeItem('pwa-install-dismissed');
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((cb) => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser-specific installation instructions
|
||||
*/
|
||||
public getInstallInstructions(): string {
|
||||
if (!browser) return 'Install instructions not available';
|
||||
/**
|
||||
* Notify all listeners of state change
|
||||
*/
|
||||
private notifyListeners(canInstall: boolean): void {
|
||||
this.listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(canInstall);
|
||||
} catch (error) {
|
||||
console.error('[PWA] Error in install state listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
|
||||
|
||||
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
|
||||
return 'Tap the Share button and select "Add to Home Screen"';
|
||||
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
|
||||
return 'Look for the install button in your browser address bar';
|
||||
} else if (userAgent.includes('edg')) {
|
||||
return 'Look for the install button in your browser address bar';
|
||||
} else if (userAgent.includes('firefox')) {
|
||||
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
|
||||
}
|
||||
|
||||
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
|
||||
}
|
||||
/**
|
||||
* Check if app is running in standalone mode (already installed)
|
||||
*/
|
||||
public isStandalone(): boolean {
|
||||
if (!browser) return false;
|
||||
|
||||
/**
|
||||
* Get current browser name for UI customization
|
||||
*/
|
||||
public getBrowserName(): string {
|
||||
if (!browser) return 'unknown';
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true ||
|
||||
document.referrer.includes('android-app://')
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
|
||||
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
|
||||
if (userAgent.includes('firefox')) return 'firefox';
|
||||
if (userAgent.includes('edg')) return 'edge';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
/**
|
||||
* Check if user has dismissed the install prompt
|
||||
*/
|
||||
public isDismissed(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem('pwa-install-dismissed') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark install prompt as dismissed by user
|
||||
*/
|
||||
public setDismissed(): void {
|
||||
if (browser) {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
console.log('[PWA] Install prompt dismissed by user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear dismissal state (called when app is installed)
|
||||
*/
|
||||
public clearDismissed(): void {
|
||||
if (browser) {
|
||||
localStorage.removeItem('pwa-install-dismissed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser-specific installation instructions
|
||||
*/
|
||||
public getInstallInstructions(): string {
|
||||
if (!browser) return 'Install instructions not available';
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isMobile = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
|
||||
|
||||
if (isMobile && userAgent.includes('safari') && !userAgent.includes('chrome')) {
|
||||
return 'Tap the Share button and select "Add to Home Screen"';
|
||||
} else if (userAgent.includes('chrome') && !userAgent.includes('edg')) {
|
||||
return 'Look for the install button in your browser address bar';
|
||||
} else if (userAgent.includes('edg')) {
|
||||
return 'Look for the install button in your browser address bar';
|
||||
} else if (userAgent.includes('firefox')) {
|
||||
return 'Firefox has limited PWA support. Try Chrome or Edge for the best experience';
|
||||
}
|
||||
|
||||
return 'Check your browser menu for "Install App" or "Add to Home Screen" options';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current browser name for UI customization
|
||||
*/
|
||||
public getBrowserName(): string {
|
||||
if (!browser) return 'unknown';
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (userAgent.includes('chrome') && !userAgent.includes('edg')) return 'chrome';
|
||||
if (userAgent.includes('safari') && !userAgent.includes('chrome')) return 'safari';
|
||||
if (userAgent.includes('firefox')) return 'firefox';
|
||||
if (userAgent.includes('edg')) return 'edge';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for application-wide use
|
||||
export const pwaInstallManager = new PWAInstallManager();
|
||||
export const pwaInstallManager = new PWAInstallManager();
|
||||
|
||||
@@ -1,379 +1,371 @@
|
||||
/**
|
||||
* 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;
|
||||
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 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;
|
||||
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
|
||||
}
|
||||
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();
|
||||
}
|
||||
/**
|
||||
* Lazy initialization - only runs in browser context
|
||||
*/
|
||||
private ensureInitialized(): void {
|
||||
if (this._initialized || !browser) return;
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
this._initialized = true;
|
||||
this.checkSupport();
|
||||
this.initializeServiceWorker();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 clientId lazily - only generates in browser context
|
||||
*/
|
||||
private get clientId(): string {
|
||||
if (!this._clientId && browser) {
|
||||
this._clientId = this.generateClientId();
|
||||
}
|
||||
return this._clientId || 'ssr-fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): NotificationState {
|
||||
this.ensureInitialized();
|
||||
return { ...this.state };
|
||||
}
|
||||
/**
|
||||
* Subscribe to state changes
|
||||
*/
|
||||
onStateChange(callback: (state: NotificationState) => void): () => void {
|
||||
this.ensureInitialized(); // Ensure initialized before sending 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';
|
||||
}
|
||||
this.listeners.push(callback);
|
||||
callback(this.state); // Send initial state
|
||||
|
||||
/**
|
||||
* Initialize service worker registration
|
||||
* SSR-safe: guarded with browser and support checks
|
||||
*/
|
||||
private async initializeServiceWorker(): Promise<void> {
|
||||
if (!browser || !this.state.supported) return;
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((cb) => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): NotificationState {
|
||||
this.ensureInitialized();
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (this.state.permission === 'granted') {
|
||||
return true;
|
||||
}
|
||||
this.state.supported =
|
||||
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
|
||||
try {
|
||||
this.state.loading = true;
|
||||
this.notifyListeners();
|
||||
this.state.permission = this.state.supported ? Notification.permission : 'denied';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Initialize service worker registration
|
||||
* SSR-safe: guarded with browser and support checks
|
||||
*/
|
||||
private async initializeServiceWorker(): Promise<void> {
|
||||
if (!browser || !this.state.supported) return;
|
||||
|
||||
/**
|
||||
* Subscribe to push notifications
|
||||
*/
|
||||
async subscribe(): Promise<boolean> {
|
||||
if (!await this.requestPermission()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// Wait for service worker to be ready
|
||||
this.registration = await navigator.serviceWorker.ready;
|
||||
console.log('[PushManager] Service worker ready');
|
||||
|
||||
if (!this.registration) {
|
||||
this.state.error = 'Service worker not ready';
|
||||
this.notifyListeners();
|
||||
return false;
|
||||
}
|
||||
// Check if already subscribed
|
||||
const subscription = await this.registration.pushManager.getSubscription();
|
||||
this.state.subscribed = !!subscription;
|
||||
|
||||
try {
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
this.notifyListeners();
|
||||
this.notifyListeners();
|
||||
} catch (error) {
|
||||
console.error('[PushManager] Service worker initialization failed:', error);
|
||||
this.state.error = 'Service worker not available';
|
||||
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)
|
||||
});
|
||||
/**
|
||||
* Request notification permission
|
||||
*/
|
||||
async requestPermission(): Promise<boolean> {
|
||||
this.ensureInitialized();
|
||||
|
||||
// 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 (!browser || !this.state.supported) {
|
||||
this.state.error = 'Push notifications not supported';
|
||||
this.notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!subscribeResponse.ok) {
|
||||
throw new Error('Failed to register subscription with server');
|
||||
}
|
||||
if (this.state.permission === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.state.subscribed = true;
|
||||
this.state.loading = false;
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('[PushManager] Successfully subscribed to push notifications');
|
||||
return true;
|
||||
try {
|
||||
this.state.loading = true;
|
||||
this.notifyListeners();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PushManager] Subscription failed:', error);
|
||||
this.state.error = 'Failed to subscribe to notifications';
|
||||
this.state.loading = false;
|
||||
this.notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const permission = await Notification.requestPermission();
|
||||
this.state.permission = permission;
|
||||
this.state.error = permission === 'denied' ? 'Permission denied' : null;
|
||||
|
||||
/**
|
||||
* Unsubscribe from push notifications
|
||||
*/
|
||||
async unsubscribe(): Promise<boolean> {
|
||||
if (!this.registration) {
|
||||
this.state.error = 'Service worker not ready';
|
||||
this.notifyListeners();
|
||||
return false;
|
||||
}
|
||||
this.state.loading = false;
|
||||
this.notifyListeners();
|
||||
|
||||
try {
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Subscribe to push notifications
|
||||
*/
|
||||
async subscribe(): Promise<boolean> {
|
||||
if (!(await this.requestPermission())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state.subscribed = false;
|
||||
this.state.loading = false;
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('[PushManager] Successfully unsubscribed from push notifications');
|
||||
return true;
|
||||
if (!this.registration) {
|
||||
this.state.error = 'Service worker not ready';
|
||||
this.notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PushManager] Unsubscription failed:', error);
|
||||
this.state.error = 'Failed to unsubscribe from notifications';
|
||||
this.state.loading = false;
|
||||
this.notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
this.notifyListeners();
|
||||
|
||||
/**
|
||||
* Toggle subscription state
|
||||
*/
|
||||
async toggleSubscription(): Promise<boolean> {
|
||||
if (this.state.subscribed) {
|
||||
return await this.unsubscribe();
|
||||
} else {
|
||||
return await this.subscribe();
|
||||
}
|
||||
}
|
||||
// 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { publicKey } = await vapidResponse.json();
|
||||
|
||||
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
localStorage.setItem('push-client-id', id);
|
||||
return id;
|
||||
}
|
||||
// Create push subscription
|
||||
const subscription = await this.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
// 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
|
||||
})
|
||||
});
|
||||
|
||||
// Input validation
|
||||
if (!base64String || typeof base64String !== 'string') {
|
||||
console.error('[PushManager] Invalid VAPID key: empty or non-string');
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (!subscribeResponse.ok) {
|
||||
throw new Error('Failed to register subscription with server');
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
this.state.subscribed = true;
|
||||
this.state.loading = false;
|
||||
this.notifyListeners();
|
||||
|
||||
// VAPID keys should be 65 characters (unpadded base64)
|
||||
if (cleanKey.length !== 65) {
|
||||
console.warn(`[PushManager] VAPID key length ${cleanKey.length}, expected 65`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Add proper padding
|
||||
const padding = '='.repeat((4 - cleanKey.length % 4) % 4);
|
||||
const base64 = (cleanKey + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
/**
|
||||
* Unsubscribe from push notifications
|
||||
*/
|
||||
async unsubscribe(): Promise<boolean> {
|
||||
if (!this.registration) {
|
||||
this.state.error = 'Service worker not ready';
|
||||
this.notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate base64 format before decoding
|
||||
const base64Regex = /^[A-Za-z0-9+\/]*={0,2}$/;
|
||||
if (!base64Regex.test(base64)) {
|
||||
throw new Error('Invalid base64 characters');
|
||||
}
|
||||
try {
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
this.notifyListeners();
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
// Get current subscription
|
||||
const subscription = await this.registration.pushManager.getSubscription();
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
if (subscription) {
|
||||
// Unsubscribe from push service
|
||||
await subscription.unsubscribe();
|
||||
|
||||
console.log(`[PushManager] Successfully decoded VAPID key (${outputArray.length} bytes)`);
|
||||
return outputArray;
|
||||
// Remove from server
|
||||
await fetch('/api/notifications/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: this.clientId
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
this.state.subscribed = false;
|
||||
this.state.loading = false;
|
||||
this.notifyListeners();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
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 };
|
||||
export type { NotificationState };
|
||||
|
||||
@@ -1,201 +1,201 @@
|
||||
/**
|
||||
* Service Worker Message Handler
|
||||
*
|
||||
*
|
||||
* Handles messages from service worker (like notification actions)
|
||||
* and coordinates with the main application.
|
||||
*/
|
||||
|
||||
import { pushState } from "$app/navigation";
|
||||
import { pushState } from '$app/navigation';
|
||||
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
action?: string;
|
||||
data?: any;
|
||||
type: string;
|
||||
action?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
class ServiceWorkerMessageHandler {
|
||||
private retryCallbacks = new Map<string, () => void>();
|
||||
private retryCallbacks = new Map<string, () => void>();
|
||||
|
||||
constructor() {
|
||||
this.initializeMessageListener();
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
switch (message.type) {
|
||||
case 'notification-action':
|
||||
this.handleNotificationAction(message.action, message.data);
|
||||
break;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
default:
|
||||
console.log('[SW-Handler] Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
pushState(url, {});
|
||||
|
||||
// Refresh page to show the item
|
||||
//window.location.reload();
|
||||
}
|
||||
}
|
||||
switch (action) {
|
||||
case 'view':
|
||||
this.handleViewAction(data.itemId);
|
||||
break;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
case 'retry':
|
||||
this.handleRetryAction(data.itemId);
|
||||
break;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
default:
|
||||
console.log('[SW-Handler] Unknown notification action:', action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register retry callback for a queue item
|
||||
*/
|
||||
registerRetryCallback(itemId: string, callback: () => void): void {
|
||||
this.retryCallbacks.set(itemId, callback);
|
||||
}
|
||||
/**
|
||||
* Handle "view" action - scroll to item and highlight
|
||||
*/
|
||||
private handleViewAction(itemId: string): void {
|
||||
console.log('[SW-Handler] View action for item:', itemId);
|
||||
|
||||
/**
|
||||
* Unregister retry callback
|
||||
*/
|
||||
unregisterRetryCallback(itemId: string): void {
|
||||
this.retryCallbacks.delete(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'
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
// 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);
|
||||
pushState(url, {});
|
||||
|
||||
/**
|
||||
* Send message to service worker
|
||||
*/
|
||||
async sendMessageToSW(message: any): Promise<any> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service worker not supported');
|
||||
}
|
||||
// Refresh page to show the item
|
||||
//window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!registration.active) {
|
||||
throw new Error('Service worker not active');
|
||||
}
|
||||
/**
|
||||
* Handle "retry" action - trigger retry for failed item
|
||||
*/
|
||||
private async handleRetryAction(itemId: string): Promise<void> {
|
||||
console.log('[SW-Handler] Retry action for item:', itemId);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = (event) => {
|
||||
resolve(event.data);
|
||||
};
|
||||
// Check if there's a registered callback
|
||||
const callback = this.retryCallbacks.get(itemId);
|
||||
if (callback) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
registration.active?.postMessage(message, [channel.port2]);
|
||||
// Fallback: direct API call
|
||||
try {
|
||||
const response = await fetch(`/api/queue/${itemId}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
reject(new Error('Service worker message timeout'));
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
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();
|
||||
export const serviceWorkerMessageHandler = new ServiceWorkerMessageHandler();
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* API Error Handler
|
||||
*
|
||||
*
|
||||
* Centralizes error handling for API endpoints by converting
|
||||
* application errors into appropriate HTTP responses.
|
||||
*
|
||||
*
|
||||
* Maps error types to status codes:
|
||||
* - ValidationError → 400 Bad Request
|
||||
* - NotFoundError → 404 Not Found
|
||||
* - ConflictError → 409 Conflict
|
||||
* - Other errors → 500 Internal Server Error
|
||||
*
|
||||
*
|
||||
* Provides consistent error response format across all API endpoints.
|
||||
*/
|
||||
|
||||
@@ -19,46 +19,56 @@ import { logError } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Handle API errors and convert to appropriate HTTP responses
|
||||
*
|
||||
*
|
||||
* @param error - Error to handle (can be any type)
|
||||
* @returns JSON response with appropriate status code and error message
|
||||
*/
|
||||
export function handleApiError(error: unknown): Response {
|
||||
// Log all errors for debugging
|
||||
logError('[API Error]', error);
|
||||
// Log all errors for debugging
|
||||
logError('[API Error]', error);
|
||||
|
||||
// Handle known error types with specific status codes
|
||||
if (error instanceof ValidationError) {
|
||||
return json({
|
||||
message: error.message,
|
||||
type: 'validation_error'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
return json({
|
||||
message: error.message,
|
||||
type: 'not_found_error'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
if (error instanceof ConflictError) {
|
||||
return json({
|
||||
message: error.message,
|
||||
type: 'conflict_error'
|
||||
}, { status: 409 });
|
||||
}
|
||||
// Handle known error types with specific status codes
|
||||
if (error instanceof ValidationError) {
|
||||
return json(
|
||||
{
|
||||
message: error.message,
|
||||
type: 'validation_error'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Handle generic errors
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
// Don't expose internal error details in production
|
||||
const publicMessage = process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: message;
|
||||
if (error instanceof NotFoundError) {
|
||||
return json(
|
||||
{
|
||||
message: error.message,
|
||||
type: 'not_found_error'
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
message: publicMessage,
|
||||
type: 'server_error'
|
||||
}, { status: 500 });
|
||||
}
|
||||
if (error instanceof ConflictError) {
|
||||
return json(
|
||||
{
|
||||
message: error.message,
|
||||
type: 'conflict_error'
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Handle generic errors
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
// Don't expose internal error details in production
|
||||
const publicMessage = process.env.NODE_ENV === 'production' ? 'Internal server error' : message;
|
||||
|
||||
return json(
|
||||
{
|
||||
message: publicMessage,
|
||||
type: 'server_error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Custom Error Classes for API Error Handling
|
||||
*
|
||||
*
|
||||
* Defines specific error types that map to HTTP status codes:
|
||||
* - ValidationError → 400 Bad Request
|
||||
* - NotFoundError → 404 Not Found
|
||||
* - NotFoundError → 404 Not Found
|
||||
* - ConflictError → 409 Conflict
|
||||
*
|
||||
*
|
||||
* Used by API endpoints to throw meaningful errors that are
|
||||
* caught and converted to proper HTTP responses by errorHandler.ts
|
||||
*/
|
||||
@@ -15,10 +15,10 @@
|
||||
* Thrown when request data is invalid or malformed
|
||||
*/
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,10 +26,10 @@ export class ValidationError extends Error {
|
||||
* Thrown when requested resource does not exist
|
||||
*/
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,8 +37,8 @@ export class NotFoundError extends Error {
|
||||
* Thrown when operation conflicts with current resource state
|
||||
*/
|
||||
export class ConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConflictError';
|
||||
}
|
||||
}
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
import { chromium } from 'playwright-extra';
|
||||
import type { Browser, BrowserContext } from 'playwright';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import fs from 'fs';
|
||||
|
||||
// Apply stealth plugin with all evasion techniques
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
let browser: Browser | null = null;
|
||||
|
||||
interface BrowserOptions {
|
||||
userAgent?: string;
|
||||
viewport?: { width: number; height: number };
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export async function initializeBrowser(): Promise<Browser> {
|
||||
if (browser) {
|
||||
return browser;
|
||||
}
|
||||
|
||||
console.log('Initializing Playwright browser...');
|
||||
|
||||
// Use environment variable or let Playwright use its bundled browser
|
||||
const executablePath = process.env.CHROMIUM_EXECUTABLE_PATH || '/usr/bin/google-chrome';
|
||||
|
||||
const launchOptions: Parameters<typeof chromium.launch>[0] = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-gpu'
|
||||
]
|
||||
};
|
||||
|
||||
// In test environment, let Playwright use bundled browser
|
||||
if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') {
|
||||
launchOptions.executablePath = executablePath;
|
||||
}
|
||||
|
||||
browser = await chromium.launch(launchOptions);
|
||||
|
||||
console.log('Browser initialized successfully');
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function getBrowser(): Promise<Browser> {
|
||||
if (!browser || !browser.isConnected()) {
|
||||
if (browser) {
|
||||
console.warn('Browser is disconnected. Re-initializing...');
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
browser = null;
|
||||
}
|
||||
return initializeBrowser();
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function createBrowserContext(
|
||||
authStoragePath?: string,
|
||||
options?: BrowserOptions
|
||||
): Promise<BrowserContext> {
|
||||
const browserInstance = await getBrowser();
|
||||
|
||||
// Default stealth options
|
||||
const defaultOptions: BrowserOptions = {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1080, height: 1920 },
|
||||
locale: 'en-US',
|
||||
timezone: 'America/New_York'
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
// Load auth if available
|
||||
let context: BrowserContext;
|
||||
const contextOptions = {
|
||||
storageState: authStoragePath && fs.existsSync(authStoragePath) ? authStoragePath : undefined,
|
||||
userAgent: finalOptions.userAgent,
|
||||
viewport: finalOptions.viewport,
|
||||
locale: finalOptions.locale,
|
||||
timezoneId: finalOptions.timezone,
|
||||
permissions: [],
|
||||
colorScheme: 'light' as const
|
||||
};
|
||||
|
||||
if (authStoragePath && fs.existsSync(authStoragePath)) {
|
||||
console.log('Loading authentication from:', authStoragePath);
|
||||
} else {
|
||||
console.warn('No auth storage found. Running as guest.');
|
||||
}
|
||||
|
||||
context = await browserInstance.newContext(contextOptions);
|
||||
|
||||
// Note: Anti-detection scripts are now handled automatically by the stealth plugin
|
||||
// The plugin applies 15+ evasion techniques including:
|
||||
// - navigator.webdriver masking
|
||||
// - chrome.runtime mocking
|
||||
// - User-Agent override
|
||||
// - WebGL fingerprinting evasion
|
||||
// - And many more...
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function closeBrowser(): Promise<void> {
|
||||
if (browser) {
|
||||
console.log('Closing Playwright browser...');
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
}
|
||||
import { chromium } from 'playwright-extra';
|
||||
import type { Browser, BrowserContext } from 'playwright';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import fs from 'fs';
|
||||
|
||||
// Apply stealth plugin with all evasion techniques
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
let browser: Browser | null = null;
|
||||
|
||||
interface BrowserOptions {
|
||||
userAgent?: string;
|
||||
viewport?: { width: number; height: number };
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export async function initializeBrowser(): Promise<Browser> {
|
||||
if (browser) {
|
||||
return browser;
|
||||
}
|
||||
|
||||
console.log('Initializing Playwright browser...');
|
||||
|
||||
// Use environment variable or let Playwright use its bundled browser
|
||||
const executablePath = process.env.CHROMIUM_EXECUTABLE_PATH || '/usr/bin/google-chrome';
|
||||
|
||||
const launchOptions: Parameters<typeof chromium.launch>[0] = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-gpu'
|
||||
]
|
||||
};
|
||||
|
||||
// In test environment, let Playwright use bundled browser
|
||||
if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') {
|
||||
launchOptions.executablePath = executablePath;
|
||||
}
|
||||
|
||||
browser = await chromium.launch(launchOptions);
|
||||
|
||||
console.log('Browser initialized successfully');
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function getBrowser(): Promise<Browser> {
|
||||
if (!browser || !browser.isConnected()) {
|
||||
if (browser) {
|
||||
console.warn('Browser is disconnected. Re-initializing...');
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
browser = null;
|
||||
}
|
||||
return initializeBrowser();
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function createBrowserContext(
|
||||
authStoragePath?: string,
|
||||
options?: BrowserOptions
|
||||
): Promise<BrowserContext> {
|
||||
const browserInstance = await getBrowser();
|
||||
|
||||
// Default stealth options
|
||||
const defaultOptions: BrowserOptions = {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1080, height: 1920 },
|
||||
locale: 'en-US',
|
||||
timezone: 'America/New_York'
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
// Load auth if available
|
||||
let context: BrowserContext;
|
||||
const contextOptions = {
|
||||
storageState: authStoragePath && fs.existsSync(authStoragePath) ? authStoragePath : undefined,
|
||||
userAgent: finalOptions.userAgent,
|
||||
viewport: finalOptions.viewport,
|
||||
locale: finalOptions.locale,
|
||||
timezoneId: finalOptions.timezone,
|
||||
permissions: [],
|
||||
colorScheme: 'light' as const
|
||||
};
|
||||
|
||||
if (authStoragePath && fs.existsSync(authStoragePath)) {
|
||||
console.log('Loading authentication from:', authStoragePath);
|
||||
} else {
|
||||
console.warn('No auth storage found. Running as guest.');
|
||||
}
|
||||
|
||||
context = await browserInstance.newContext(contextOptions);
|
||||
|
||||
// Note: Anti-detection scripts are now handled automatically by the stealth plugin
|
||||
// The plugin applies 15+ evasion techniques including:
|
||||
// - navigator.webdriver masking
|
||||
// - chrome.runtime mocking
|
||||
// - User-Agent override
|
||||
// - WebGL fingerprinting evasion
|
||||
// - And many more...
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function closeBrowser(): Promise<void> {
|
||||
if (browser) {
|
||||
console.log('Closing Playwright browser...');
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,9 +56,9 @@ export async function checkModelAvailability(
|
||||
const { client } = createLLM();
|
||||
const response = await client.models.list();
|
||||
const models = response.data || [];
|
||||
|
||||
|
||||
const foundModel = models.find((m) => m.id === model);
|
||||
|
||||
|
||||
if (foundModel) {
|
||||
console.log('[LLM] Model available:', model);
|
||||
return { available: true };
|
||||
@@ -78,4 +78,4 @@ export async function checkModelAvailability(
|
||||
message: `Failed to check model availability: ${(e as Error).message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Push Notification Service for InstaRecipe Queue System
|
||||
*
|
||||
*
|
||||
* Handles web push notifications for background processing updates
|
||||
* when users are not actively viewing the application.
|
||||
*/
|
||||
@@ -10,233 +10,237 @@ import webpush from 'web-push';
|
||||
import { queueConfig } from '../queue/config';
|
||||
|
||||
interface PushSubscription {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
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;
|
||||
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;
|
||||
private subscriptions = new Map<string, PushSubscription>();
|
||||
private vapidKeys: { publicKey: string; privateKey: string } | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadVapidKeys();
|
||||
|
||||
// Configure web-push with VAPID details
|
||||
if (this.vapidKeys) {
|
||||
webpush.setVapidDetails(
|
||||
queueConfig.push.vapidEmail,
|
||||
this.vapidKeys.publicKey,
|
||||
this.vapidKeys.privateKey
|
||||
);
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
// Configure web-push with VAPID details
|
||||
if (this.vapidKeys) {
|
||||
webpush.setVapidDetails(
|
||||
queueConfig.push.vapidEmail,
|
||||
this.vapidKeys.publicKey,
|
||||
this.vapidKeys.privateKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public VAPID key for client-side subscription
|
||||
*/
|
||||
getPublicVapidKey(): string | null {
|
||||
return this.vapidKeys?.publicKey || null;
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
/**
|
||||
* Get the public VAPID key for client-side subscription
|
||||
*/
|
||||
getPublicVapidKey(): string | null {
|
||||
return this.vapidKeys?.publicKey || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a client from push notifications
|
||||
*/
|
||||
async unsubscribe(clientId: string): Promise<void> {
|
||||
console.log(`[PushService] Unsubscribing client ${clientId}`);
|
||||
this.subscriptions.delete(clientId);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
// In production, store subscriptions in database
|
||||
// For development, we'll keep them in memory
|
||||
}
|
||||
|
||||
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
|
||||
console.log(`[PushService] Notification payload:`, payload);
|
||||
/**
|
||||
* Unsubscribe a client from push notifications
|
||||
*/
|
||||
async unsubscribe(clientId: string): Promise<void> {
|
||||
console.log(`[PushService] Unsubscribing client ${clientId}`);
|
||||
this.subscriptions.delete(clientId);
|
||||
}
|
||||
|
||||
// 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()
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
|
||||
console.log(`[PushService] Notification payload:`, payload);
|
||||
|
||||
/**
|
||||
* Send notification to specific subscription
|
||||
*/
|
||||
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
|
||||
try {
|
||||
const payload = JSON.stringify(data);
|
||||
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth
|
||||
}
|
||||
},
|
||||
payload,
|
||||
{
|
||||
TTL: 60 * 60 * 24, // 24 hours
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`);
|
||||
} catch (error) {
|
||||
// Check if subscription is expired/invalid
|
||||
if ((error as any).statusCode === 410) {
|
||||
console.warn(`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`);
|
||||
throw new Error('Subscription expired');
|
||||
}
|
||||
|
||||
console.error('[PushService] Failed to send notification:', {
|
||||
endpoint: subscription.endpoint.substring(0, 50) + '...',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
};
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tandoorUrl) {
|
||||
payload.body += ' View it in Tandoor.';
|
||||
}
|
||||
/**
|
||||
* Send notification to specific subscription
|
||||
*/
|
||||
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
|
||||
try {
|
||||
const payload = JSON.stringify(data);
|
||||
|
||||
await this.sendNotification(payload);
|
||||
}
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth
|
||||
}
|
||||
},
|
||||
payload,
|
||||
{
|
||||
TTL: 60 * 60 * 24 // 24 hours
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
};
|
||||
console.log(
|
||||
`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`
|
||||
);
|
||||
} catch (error) {
|
||||
// Check if subscription is expired/invalid
|
||||
if ((error as any).statusCode === 410) {
|
||||
console.warn(
|
||||
`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`
|
||||
);
|
||||
throw new Error('Subscription expired');
|
||||
}
|
||||
|
||||
await this.sendNotification(payload);
|
||||
}
|
||||
console.error('[PushService] Failed to send notification:', {
|
||||
endpoint: subscription.endpoint.substring(0, 50) + '...',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
};
|
||||
|
||||
await this.sendNotification(payload);
|
||||
}
|
||||
if (tandoorUrl) {
|
||||
payload.body += ' View it in Tandoor.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription count for monitoring
|
||||
*/
|
||||
getSubscriptionCount(): number {
|
||||
return this.subscriptions.size;
|
||||
}
|
||||
await this.sendNotification(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all subscriptions (for testing/cleanup)
|
||||
*/
|
||||
clearAllSubscriptions(): void {
|
||||
console.log('[PushService] Clearing all subscriptions');
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
/**
|
||||
* 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 };
|
||||
export type { PushSubscription, NotificationPayload };
|
||||
|
||||
@@ -1,208 +1,212 @@
|
||||
import { createLLM, checkModelAvailability } from './llm';
|
||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||
import { z } from 'zod';
|
||||
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
|
||||
import { logError } from './utils/logger';
|
||||
|
||||
const RecipeSchema = z.object({
|
||||
name: z.string(),
|
||||
servings: z.number().nullable(),
|
||||
description: z.string().nullable(),
|
||||
ingredients: z.array(
|
||||
z.object({
|
||||
item: z.string(),
|
||||
amount: z.string(),
|
||||
unit: z.string()
|
||||
})
|
||||
).nullable(),
|
||||
steps: z.array(z.string()).nullable(),
|
||||
image: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type Recipe = z.infer<typeof RecipeSchema>;
|
||||
|
||||
/**
|
||||
* Detect if the text contains a recipe using binary classification
|
||||
* @param text - The text to analyze
|
||||
* @returns True if a recipe is detected, false otherwise
|
||||
*/
|
||||
export async function detectRecipe(text: string): Promise<boolean> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Starting recipe detection...');
|
||||
console.log('[LLM] Model:', model);
|
||||
console.log('[LLM] Text length:', text.length);
|
||||
|
||||
const detectionResponse = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: RECIPE_DETECTION_PROMPT
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Does this text contain a recipe?\n\n${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 10,
|
||||
temperature: 0
|
||||
});
|
||||
|
||||
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
|
||||
console.log('[LLM] Detection response:', detectionResult);
|
||||
|
||||
return detectionResult.includes('yes');
|
||||
} catch (e) {
|
||||
logError('[LLM] Recipe detection error', e);
|
||||
|
||||
// Check if this is a model-related error
|
||||
const errorMessage = (e as Error).message || '';
|
||||
const isModelError = errorMessage.includes('400') &&
|
||||
(errorMessage.toLowerCase().includes('model') ||
|
||||
errorMessage.toLowerCase().includes('load'));
|
||||
|
||||
if (isModelError) {
|
||||
const { model } = createLLM();
|
||||
const modelCheck = await checkModelAvailability(model);
|
||||
if (!modelCheck.available) {
|
||||
throw new Error(modelCheck.message || `Model "${model}" is not available`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to detect recipe: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract recipe data from text using LLM structured output
|
||||
* @param text - The text containing the recipe
|
||||
* @returns Parsed recipe object
|
||||
*/
|
||||
export async function parseRecipe(text: string): Promise<Recipe> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Starting recipe parsing...');
|
||||
console.log('[LLM] Model:', model);
|
||||
|
||||
const completion = await client.beta.chat.completions.parse({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: RECIPE_EXTRACTION_PROMPT
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Extract the recipe from this text:\n\n${text}`
|
||||
}
|
||||
],
|
||||
response_format: zodResponseFormat(RecipeSchema, 'recipe'),
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
const recipe = completion.choices[0].message.parsed;
|
||||
console.log('[LLM] Parse response:', recipe?.name);
|
||||
|
||||
if (!recipe || !recipe.name) {
|
||||
throw new Error('Failed to extract recipe - missing name');
|
||||
}
|
||||
|
||||
return recipe;
|
||||
} catch (e) {
|
||||
logError('[LLM] Recipe parsing error', e);
|
||||
|
||||
// Check if this is a model-related error
|
||||
const errorMessage = (e as Error).message || '';
|
||||
const isModelError = errorMessage.includes('400') &&
|
||||
(errorMessage.toLowerCase().includes('model') ||
|
||||
errorMessage.toLowerCase().includes('load'));
|
||||
|
||||
if (isModelError) {
|
||||
const { model } = createLLM();
|
||||
const modelCheck = await checkModelAvailability(model);
|
||||
if (!modelCheck.available) {
|
||||
throw new Error(modelCheck.message || `Model "${model}" is not available`);
|
||||
}
|
||||
}
|
||||
|
||||
// If structured output fails, try standard completion
|
||||
if ((e as any).message?.includes('response_format') ||
|
||||
(e as any).message?.includes('structured output')) {
|
||||
console.warn('[LLM] Falling back to standard completion');
|
||||
return await parseRecipeWithStandardCompletion(text);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to parse recipe: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete workflow: detect recipe and parse if found
|
||||
* @param text - The text to analyze
|
||||
* @returns Parsed recipe object if detected, null otherwise
|
||||
*/
|
||||
export async function extractRecipe(text: string): Promise<Recipe | null> {
|
||||
const isRecipe = await detectRecipe(text);
|
||||
|
||||
if (!isRecipe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseRecipe(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback parser using standard completion (no structured output)
|
||||
* Used when the model doesn't support beta.chat.completions.parse()
|
||||
*/
|
||||
async function parseRecipeWithStandardCompletion(text: string): Promise<Recipe> {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Using standard completion fallback');
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a recipe extractor. Return ONLY valid JSON matching this schema:
|
||||
{
|
||||
"name": "recipe name in Italian",
|
||||
"servings": number or null,
|
||||
"description": "description in Italian or null",
|
||||
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
|
||||
"steps": ["First step", "Second step", ...]
|
||||
}
|
||||
|
||||
Convert all measurements to SI units (g, mL, °C).
|
||||
Translate everything to Italian.
|
||||
Extract ONLY what's in the text.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Extract the recipe from this text:\n\n${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 2000,
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
const jsonResponse = completion.choices[0].message.content;
|
||||
if (!jsonResponse) {
|
||||
throw new Error('Empty response from LLM');
|
||||
}
|
||||
|
||||
console.log('[LLM] Standard completion raw response:', jsonResponse.substring(0, 200));
|
||||
|
||||
// Parse and validate JSON (remove code fences if present)
|
||||
const cleanedJson = jsonResponse.replace(/```json\n?|```\n?/g, '').trim();
|
||||
const parsedData = JSON.parse(cleanedJson);
|
||||
const recipe = RecipeSchema.parse(parsedData);
|
||||
|
||||
console.log('[LLM] Standard completion parsed recipe:', recipe.name);
|
||||
|
||||
return recipe;
|
||||
}
|
||||
import { createLLM, checkModelAvailability } from './llm';
|
||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||
import { z } from 'zod';
|
||||
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
|
||||
import { logError } from './utils/logger';
|
||||
|
||||
const RecipeSchema = z.object({
|
||||
name: z.string(),
|
||||
servings: z.number().nullable(),
|
||||
description: z.string().nullable(),
|
||||
ingredients: z
|
||||
.array(
|
||||
z.object({
|
||||
item: z.string(),
|
||||
amount: z.string(),
|
||||
unit: z.string()
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
steps: z.array(z.string()).nullable(),
|
||||
image: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type Recipe = z.infer<typeof RecipeSchema>;
|
||||
|
||||
/**
|
||||
* Detect if the text contains a recipe using binary classification
|
||||
* @param text - The text to analyze
|
||||
* @returns True if a recipe is detected, false otherwise
|
||||
*/
|
||||
export async function detectRecipe(text: string): Promise<boolean> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Starting recipe detection...');
|
||||
console.log('[LLM] Model:', model);
|
||||
console.log('[LLM] Text length:', text.length);
|
||||
|
||||
const detectionResponse = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: RECIPE_DETECTION_PROMPT
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Does this text contain a recipe?\n\n${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 10,
|
||||
temperature: 0
|
||||
});
|
||||
|
||||
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
|
||||
console.log('[LLM] Detection response:', detectionResult);
|
||||
|
||||
return detectionResult.includes('yes');
|
||||
} catch (e) {
|
||||
logError('[LLM] Recipe detection error', e);
|
||||
|
||||
// Check if this is a model-related error
|
||||
const errorMessage = (e as Error).message || '';
|
||||
const isModelError =
|
||||
errorMessage.includes('400') &&
|
||||
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
|
||||
|
||||
if (isModelError) {
|
||||
const { model } = createLLM();
|
||||
const modelCheck = await checkModelAvailability(model);
|
||||
if (!modelCheck.available) {
|
||||
throw new Error(modelCheck.message || `Model "${model}" is not available`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to detect recipe: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract recipe data from text using LLM structured output
|
||||
* @param text - The text containing the recipe
|
||||
* @returns Parsed recipe object
|
||||
*/
|
||||
export async function parseRecipe(text: string): Promise<Recipe> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Starting recipe parsing...');
|
||||
console.log('[LLM] Model:', model);
|
||||
|
||||
const completion = await client.beta.chat.completions.parse({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: RECIPE_EXTRACTION_PROMPT
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Extract the recipe from this text:\n\n${text}`
|
||||
}
|
||||
],
|
||||
response_format: zodResponseFormat(RecipeSchema, 'recipe'),
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
const recipe = completion.choices[0].message.parsed;
|
||||
console.log('[LLM] Parse response:', recipe?.name);
|
||||
|
||||
if (!recipe || !recipe.name) {
|
||||
throw new Error('Failed to extract recipe - missing name');
|
||||
}
|
||||
|
||||
return recipe;
|
||||
} catch (e) {
|
||||
logError('[LLM] Recipe parsing error', e);
|
||||
|
||||
// Check if this is a model-related error
|
||||
const errorMessage = (e as Error).message || '';
|
||||
const isModelError =
|
||||
errorMessage.includes('400') &&
|
||||
(errorMessage.toLowerCase().includes('model') || errorMessage.toLowerCase().includes('load'));
|
||||
|
||||
if (isModelError) {
|
||||
const { model } = createLLM();
|
||||
const modelCheck = await checkModelAvailability(model);
|
||||
if (!modelCheck.available) {
|
||||
throw new Error(modelCheck.message || `Model "${model}" is not available`);
|
||||
}
|
||||
}
|
||||
|
||||
// If structured output fails, try standard completion
|
||||
if (
|
||||
(e as any).message?.includes('response_format') ||
|
||||
(e as any).message?.includes('structured output')
|
||||
) {
|
||||
console.warn('[LLM] Falling back to standard completion');
|
||||
return await parseRecipeWithStandardCompletion(text);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to parse recipe: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete workflow: detect recipe and parse if found
|
||||
* @param text - The text to analyze
|
||||
* @returns Parsed recipe object if detected, null otherwise
|
||||
*/
|
||||
export async function extractRecipe(text: string): Promise<Recipe | null> {
|
||||
const isRecipe = await detectRecipe(text);
|
||||
|
||||
if (!isRecipe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseRecipe(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback parser using standard completion (no structured output)
|
||||
* Used when the model doesn't support beta.chat.completions.parse()
|
||||
*/
|
||||
async function parseRecipeWithStandardCompletion(text: string): Promise<Recipe> {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Using standard completion fallback');
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a recipe extractor. Return ONLY valid JSON matching this schema:
|
||||
{
|
||||
"name": "recipe name in Italian",
|
||||
"servings": number or null,
|
||||
"description": "description in Italian or null",
|
||||
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
|
||||
"steps": ["First step", "Second step", ...]
|
||||
}
|
||||
|
||||
Convert all measurements to SI units (g, mL, °C).
|
||||
Translate everything to Italian.
|
||||
Extract ONLY what's in the text.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Extract the recipe from this text:\n\n${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 2000,
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
const jsonResponse = completion.choices[0].message.content;
|
||||
if (!jsonResponse) {
|
||||
throw new Error('Empty response from LLM');
|
||||
}
|
||||
|
||||
console.log('[LLM] Standard completion raw response:', jsonResponse.substring(0, 200));
|
||||
|
||||
// Parse and validate JSON (remove code fences if present)
|
||||
const cleanedJson = jsonResponse.replace(/```json\n?|```\n?/g, '').trim();
|
||||
const parsedData = JSON.parse(cleanedJson);
|
||||
const recipe = RecipeSchema.parse(parsedData);
|
||||
|
||||
console.log('[LLM] Standard completion parsed recipe:', recipe.name);
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Queue Manager - Core queue operations and event management
|
||||
*
|
||||
*
|
||||
* Manages an in-memory queue of Instagram URL processing jobs.
|
||||
* Provides CRUD operations and pub/sub mechanism for queue updates.
|
||||
*
|
||||
*
|
||||
* Architecture: Domain Layer (Hexagonal Architecture)
|
||||
* - Port: Defines queue operations interface
|
||||
* - Implementation: In-memory Map-based storage
|
||||
@@ -16,427 +16,428 @@ import type { QueueItem, QueueItemStatus, QueueStatusUpdate, QueueUpdateCallback
|
||||
|
||||
/**
|
||||
* Singleton queue manager for processing Instagram URLs
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* - FIFO queue with unique IDs
|
||||
* - Status tracking and updates
|
||||
* - Progress event accumulation
|
||||
* - Retry support for failed items
|
||||
* - Pub/sub for real-time updates
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { queueManager } from './QueueManager';
|
||||
*
|
||||
*
|
||||
* // Add item to queue
|
||||
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
|
||||
*
|
||||
*
|
||||
* // Subscribe to updates
|
||||
* const unsubscribe = queueManager.subscribe((update) => {
|
||||
* console.log('Item updated:', update);
|
||||
* });
|
||||
*
|
||||
*
|
||||
* // Get all items
|
||||
* const items = queueManager.getAll();
|
||||
* ```
|
||||
*/
|
||||
export class QueueManager {
|
||||
/** Map of queue items by ID */
|
||||
private items: Map<string, QueueItem> = new Map();
|
||||
|
||||
/** Set of subscriber callbacks */
|
||||
private subscribers: Set<QueueUpdateCallback> = new Set();
|
||||
|
||||
/**
|
||||
* Add URL to processing queue
|
||||
*
|
||||
* @param url - Instagram URL to process
|
||||
* @returns Newly created queue item
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
|
||||
* console.log('Queued with ID:', item.id);
|
||||
* ```
|
||||
*/
|
||||
enqueue(url: string): QueueItem {
|
||||
const now = new Date().toISOString();
|
||||
const item: QueueItem = {
|
||||
id: uuidv4(),
|
||||
url,
|
||||
status: 'pending',
|
||||
enqueuedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
phases: [
|
||||
{ name: 'extraction', status: 'pending' },
|
||||
{ name: 'parsing', status: 'pending' },
|
||||
{ name: 'uploading', status: 'pending' }
|
||||
],
|
||||
logs: [],
|
||||
progressEvents: [],
|
||||
retryCount: 0,
|
||||
maxRetries: 3
|
||||
};
|
||||
|
||||
this.items.set(item.id, item);
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId: item.id,
|
||||
status: 'pending',
|
||||
url: item.url,
|
||||
timestamp: now,
|
||||
progress: item.phases
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next pending item for processing (FIFO)
|
||||
*
|
||||
* Automatically marks the item as in_progress when dequeued.
|
||||
*
|
||||
* @returns Next pending item, or null if queue is empty
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const item = queueManager.dequeue();
|
||||
* if (item) {
|
||||
* // Process item
|
||||
* console.log('Processing:', item.url);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
dequeue(): QueueItem | null {
|
||||
for (const item of this.items.values()) {
|
||||
if (item.status === 'pending') {
|
||||
this.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item status and optional data
|
||||
*
|
||||
* Handles status-specific logic:
|
||||
* - Sets startedAt when transitioning to in_progress
|
||||
* - Sets completedAt when transitioning to success/error
|
||||
* - Updates currentPhase for in_progress status
|
||||
*
|
||||
* @param itemId - ID of item to update
|
||||
* @param status - New status
|
||||
* @param data - Optional additional data to merge into item
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* queueManager.updateStatus(itemId, 'in_progress', {
|
||||
* phase: 'parsing'
|
||||
* });
|
||||
*
|
||||
* queueManager.updateStatus(itemId, 'success', {
|
||||
* recipe: parsedRecipe,
|
||||
* tandoorRecipeId: 123
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
updateStatus(
|
||||
itemId: string,
|
||||
status: QueueItemStatus,
|
||||
data?: any
|
||||
): void {
|
||||
const item = this.items.get(itemId);
|
||||
if (!item) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
item.status = status;
|
||||
item.updatedAt = now;
|
||||
|
||||
// Update phase progress
|
||||
if (status === 'in_progress' && data?.phase) {
|
||||
item.currentPhase = data.phase;
|
||||
|
||||
if (!item.startedAt) {
|
||||
item.startedAt = now;
|
||||
}
|
||||
|
||||
// Update phases array
|
||||
const phaseIndex = item.phases.findIndex(p => p.name === data.phase);
|
||||
if (phaseIndex >= 0) {
|
||||
// Mark previous phases as completed
|
||||
for (let i = 0; i < phaseIndex; i++) {
|
||||
if (item.phases[i].status === 'in_progress') {
|
||||
item.phases[i].status = 'completed';
|
||||
item.phases[i].completedAt = now;
|
||||
}
|
||||
}
|
||||
// Mark current phase as in progress
|
||||
item.phases[phaseIndex].status = 'in_progress';
|
||||
item.phases[phaseIndex].startedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
item.completedAt = now;
|
||||
// Mark all phases as completed
|
||||
item.phases.forEach(phase => {
|
||||
if (phase.status !== 'completed') {
|
||||
phase.status = 'completed';
|
||||
phase.completedAt = now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 'error' || status === 'unhealthy') {
|
||||
item.completedAt = now;
|
||||
// Mark current phase as error
|
||||
if (item.currentPhase) {
|
||||
const phaseIndex = item.phases.findIndex(p => p.name === item.currentPhase);
|
||||
if (phaseIndex >= 0) {
|
||||
item.phases[phaseIndex].status = 'error';
|
||||
item.phases[phaseIndex].error = data?.error?.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap results in results object
|
||||
if (data?.extractedText || data?.thumbnail !== undefined || data?.recipe || data?.tandoorRecipeId) {
|
||||
if (!item.results) {
|
||||
item.results = {};
|
||||
}
|
||||
|
||||
if (data.extractedText) {
|
||||
item.results.extractedText = data.extractedText;
|
||||
item.extractedText = data.extractedText; // Keep legacy
|
||||
}
|
||||
if (data.thumbnail !== undefined) {
|
||||
item.results.thumbnail = data.thumbnail;
|
||||
item.thumbnail = data.thumbnail; // Keep legacy
|
||||
}
|
||||
if (data.recipe) {
|
||||
item.results.recipe = data.recipe;
|
||||
item.recipe = data.recipe; // Keep legacy
|
||||
}
|
||||
if (data.tandoorRecipeId) {
|
||||
item.results.tandoorRecipeId = data.tandoorRecipeId;
|
||||
item.tandoorRecipeId = data.tandoorRecipeId; // Keep legacy
|
||||
|
||||
// Construct Tandoor URL
|
||||
if (tandoorConfig.serverUrl) {
|
||||
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.error) {
|
||||
item.error = data.error;
|
||||
}
|
||||
|
||||
// Notify subscribers with enhanced update
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId,
|
||||
status,
|
||||
timestamp: now,
|
||||
url: item.url,
|
||||
phase: item.currentPhase,
|
||||
progress: item.phases,
|
||||
results: item.results,
|
||||
error: item.error,
|
||||
...data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add progress event to item's history
|
||||
*
|
||||
* Also extracts message into logs array for easy display.
|
||||
*
|
||||
* @param itemId - ID of item
|
||||
* @param event - Progress event to add
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* queueManager.addProgressEvent(itemId, {
|
||||
* type: 'status',
|
||||
* message: 'Extracting from Instagram...',
|
||||
* timestamp: new Date().toISOString()
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
addProgressEvent(itemId: string, event: any): void {
|
||||
const item = this.items.get(itemId);
|
||||
if (!item) return;
|
||||
|
||||
item.progressEvents.push(event);
|
||||
item.logs.push(event.message);
|
||||
|
||||
this.notifySubscribers({
|
||||
type: 'progress',
|
||||
itemId,
|
||||
status: item.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { event }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from queue
|
||||
*
|
||||
* @param itemId - ID of item to remove
|
||||
* @returns true if item was removed, false if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const removed = queueManager.remove(itemId);
|
||||
* if (removed) {
|
||||
* console.log('Item removed successfully');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
remove(itemId: string): boolean {
|
||||
const deleted = this.items.delete(itemId);
|
||||
if (deleted) {
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId,
|
||||
status: 'error', // Use error to signal removal
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { removed: true }
|
||||
});
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed or unhealthy item
|
||||
*
|
||||
* Resets item to pending status and clears error state.
|
||||
* Cannot retry items currently in progress.
|
||||
*
|
||||
* @param itemId - ID of item to retry
|
||||
* @returns true if retry was initiated, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const retried = queueManager.retry(itemId);
|
||||
* if (retried) {
|
||||
* console.log('Item queued for retry');
|
||||
* } else {
|
||||
* console.log('Cannot retry (item in progress or not found)');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
retry(itemId: string): boolean {
|
||||
const item = this.items.get(itemId);
|
||||
if (!item || item.status === 'in_progress') return false;
|
||||
|
||||
item.retryCount++;
|
||||
item.status = 'pending';
|
||||
item.currentPhase = undefined;
|
||||
item.error = undefined;
|
||||
item.startedAt = undefined;
|
||||
item.completedAt = undefined;
|
||||
|
||||
// Reset phases to pending
|
||||
item.phases = [
|
||||
{ name: 'extraction', status: 'pending' },
|
||||
{ name: 'parsing', status: 'pending' },
|
||||
{ name: 'uploading', status: 'pending' }
|
||||
];
|
||||
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId,
|
||||
status: 'pending',
|
||||
timestamp: new Date().toISOString(),
|
||||
progress: item.phases,
|
||||
data: { retryCount: item.retryCount }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all queue items
|
||||
*
|
||||
* @returns Array of all queue items
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const items = queueManager.getAll();
|
||||
* console.log(`Queue has ${items.length} items`);
|
||||
* ```
|
||||
*/
|
||||
getAll(): QueueItem[] {
|
||||
return Array.from(this.items.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single item by ID
|
||||
*
|
||||
* @param itemId - ID of item to retrieve
|
||||
* @returns Queue item or undefined if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const item = queueManager.get(itemId);
|
||||
* if (item) {
|
||||
* console.log('Status:', item.status);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
get(itemId: string): QueueItem | undefined {
|
||||
return this.items.get(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to queue updates
|
||||
*
|
||||
* Callback will be called whenever any item is updated.
|
||||
*
|
||||
* @param callback - Function to call on each update
|
||||
* @returns Unsubscribe function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const unsubscribe = queueManager.subscribe((update) => {
|
||||
* console.log('Update:', update.itemId, update.status);
|
||||
* });
|
||||
*
|
||||
* // Later...
|
||||
* unsubscribe();
|
||||
* ```
|
||||
*/
|
||||
subscribe(callback: QueueUpdateCallback): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all subscribers of an update
|
||||
*
|
||||
* Handles errors in individual subscribers to prevent one
|
||||
* bad subscriber from affecting others.
|
||||
*
|
||||
* @param update - Update to broadcast
|
||||
*/
|
||||
private notifySubscribers(update: QueueStatusUpdate): void {
|
||||
for (const callback of this.subscribers) {
|
||||
try {
|
||||
callback(update);
|
||||
} catch (err) {
|
||||
logError('[QueueManager] Subscriber error', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Map of queue items by ID */
|
||||
private items: Map<string, QueueItem> = new Map();
|
||||
|
||||
/** Set of subscriber callbacks */
|
||||
private subscribers: Set<QueueUpdateCallback> = new Set();
|
||||
|
||||
/**
|
||||
* Add URL to processing queue
|
||||
*
|
||||
* @param url - Instagram URL to process
|
||||
* @returns Newly created queue item
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const item = queueManager.enqueue('https://instagram.com/p/abc123');
|
||||
* console.log('Queued with ID:', item.id);
|
||||
* ```
|
||||
*/
|
||||
enqueue(url: string): QueueItem {
|
||||
const now = new Date().toISOString();
|
||||
const item: QueueItem = {
|
||||
id: uuidv4(),
|
||||
url,
|
||||
status: 'pending',
|
||||
enqueuedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
phases: [
|
||||
{ name: 'extraction', status: 'pending' },
|
||||
{ name: 'parsing', status: 'pending' },
|
||||
{ name: 'uploading', status: 'pending' }
|
||||
],
|
||||
logs: [],
|
||||
progressEvents: [],
|
||||
retryCount: 0,
|
||||
maxRetries: 3
|
||||
};
|
||||
|
||||
this.items.set(item.id, item);
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId: item.id,
|
||||
status: 'pending',
|
||||
url: item.url,
|
||||
timestamp: now,
|
||||
progress: item.phases
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next pending item for processing (FIFO)
|
||||
*
|
||||
* Automatically marks the item as in_progress when dequeued.
|
||||
*
|
||||
* @returns Next pending item, or null if queue is empty
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const item = queueManager.dequeue();
|
||||
* if (item) {
|
||||
* // Process item
|
||||
* console.log('Processing:', item.url);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
dequeue(): QueueItem | null {
|
||||
for (const item of this.items.values()) {
|
||||
if (item.status === 'pending') {
|
||||
this.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item status and optional data
|
||||
*
|
||||
* Handles status-specific logic:
|
||||
* - Sets startedAt when transitioning to in_progress
|
||||
* - Sets completedAt when transitioning to success/error
|
||||
* - Updates currentPhase for in_progress status
|
||||
*
|
||||
* @param itemId - ID of item to update
|
||||
* @param status - New status
|
||||
* @param data - Optional additional data to merge into item
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* queueManager.updateStatus(itemId, 'in_progress', {
|
||||
* phase: 'parsing'
|
||||
* });
|
||||
*
|
||||
* queueManager.updateStatus(itemId, 'success', {
|
||||
* recipe: parsedRecipe,
|
||||
* tandoorRecipeId: 123
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
updateStatus(itemId: string, status: QueueItemStatus, data?: any): void {
|
||||
const item = this.items.get(itemId);
|
||||
if (!item) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
item.status = status;
|
||||
item.updatedAt = now;
|
||||
|
||||
// Update phase progress
|
||||
if (status === 'in_progress' && data?.phase) {
|
||||
item.currentPhase = data.phase;
|
||||
|
||||
if (!item.startedAt) {
|
||||
item.startedAt = now;
|
||||
}
|
||||
|
||||
// Update phases array
|
||||
const phaseIndex = item.phases.findIndex((p) => p.name === data.phase);
|
||||
if (phaseIndex >= 0) {
|
||||
// Mark previous phases as completed
|
||||
for (let i = 0; i < phaseIndex; i++) {
|
||||
if (item.phases[i].status === 'in_progress') {
|
||||
item.phases[i].status = 'completed';
|
||||
item.phases[i].completedAt = now;
|
||||
}
|
||||
}
|
||||
// Mark current phase as in progress
|
||||
item.phases[phaseIndex].status = 'in_progress';
|
||||
item.phases[phaseIndex].startedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
item.completedAt = now;
|
||||
// Mark all phases as completed
|
||||
item.phases.forEach((phase) => {
|
||||
if (phase.status !== 'completed') {
|
||||
phase.status = 'completed';
|
||||
phase.completedAt = now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (status === 'error' || status === 'unhealthy') {
|
||||
item.completedAt = now;
|
||||
// Mark current phase as error
|
||||
if (item.currentPhase) {
|
||||
const phaseIndex = item.phases.findIndex((p) => p.name === item.currentPhase);
|
||||
if (phaseIndex >= 0) {
|
||||
item.phases[phaseIndex].status = 'error';
|
||||
item.phases[phaseIndex].error = data?.error?.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap results in results object
|
||||
if (
|
||||
data?.extractedText ||
|
||||
data?.thumbnail !== undefined ||
|
||||
data?.recipe ||
|
||||
data?.tandoorRecipeId
|
||||
) {
|
||||
if (!item.results) {
|
||||
item.results = {};
|
||||
}
|
||||
|
||||
if (data.extractedText) {
|
||||
item.results.extractedText = data.extractedText;
|
||||
item.extractedText = data.extractedText; // Keep legacy
|
||||
}
|
||||
if (data.thumbnail !== undefined) {
|
||||
item.results.thumbnail = data.thumbnail;
|
||||
item.thumbnail = data.thumbnail; // Keep legacy
|
||||
}
|
||||
if (data.recipe) {
|
||||
item.results.recipe = data.recipe;
|
||||
item.recipe = data.recipe; // Keep legacy
|
||||
}
|
||||
if (data.tandoorRecipeId) {
|
||||
item.results.tandoorRecipeId = data.tandoorRecipeId;
|
||||
item.tandoorRecipeId = data.tandoorRecipeId; // Keep legacy
|
||||
|
||||
// Construct Tandoor URL
|
||||
if (tandoorConfig.serverUrl) {
|
||||
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.error) {
|
||||
item.error = data.error;
|
||||
}
|
||||
|
||||
// Notify subscribers with enhanced update
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId,
|
||||
status,
|
||||
timestamp: now,
|
||||
url: item.url,
|
||||
phase: item.currentPhase,
|
||||
progress: item.phases,
|
||||
results: item.results,
|
||||
error: item.error,
|
||||
...data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add progress event to item's history
|
||||
*
|
||||
* Also extracts message into logs array for easy display.
|
||||
*
|
||||
* @param itemId - ID of item
|
||||
* @param event - Progress event to add
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* queueManager.addProgressEvent(itemId, {
|
||||
* type: 'status',
|
||||
* message: 'Extracting from Instagram...',
|
||||
* timestamp: new Date().toISOString()
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
addProgressEvent(itemId: string, event: any): void {
|
||||
const item = this.items.get(itemId);
|
||||
if (!item) return;
|
||||
|
||||
item.progressEvents.push(event);
|
||||
item.logs.push(event.message);
|
||||
|
||||
this.notifySubscribers({
|
||||
type: 'progress',
|
||||
itemId,
|
||||
status: item.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { event }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from queue
|
||||
*
|
||||
* @param itemId - ID of item to remove
|
||||
* @returns true if item was removed, false if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const removed = queueManager.remove(itemId);
|
||||
* if (removed) {
|
||||
* console.log('Item removed successfully');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
remove(itemId: string): boolean {
|
||||
const deleted = this.items.delete(itemId);
|
||||
if (deleted) {
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId,
|
||||
status: 'error', // Use error to signal removal
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { removed: true }
|
||||
});
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed or unhealthy item
|
||||
*
|
||||
* Resets item to pending status and clears error state.
|
||||
* Cannot retry items currently in progress.
|
||||
*
|
||||
* @param itemId - ID of item to retry
|
||||
* @returns true if retry was initiated, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const retried = queueManager.retry(itemId);
|
||||
* if (retried) {
|
||||
* console.log('Item queued for retry');
|
||||
* } else {
|
||||
* console.log('Cannot retry (item in progress or not found)');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
retry(itemId: string): boolean {
|
||||
const item = this.items.get(itemId);
|
||||
if (!item || item.status === 'in_progress') return false;
|
||||
|
||||
item.retryCount++;
|
||||
item.status = 'pending';
|
||||
item.currentPhase = undefined;
|
||||
item.error = undefined;
|
||||
item.startedAt = undefined;
|
||||
item.completedAt = undefined;
|
||||
|
||||
// Reset phases to pending
|
||||
item.phases = [
|
||||
{ name: 'extraction', status: 'pending' },
|
||||
{ name: 'parsing', status: 'pending' },
|
||||
{ name: 'uploading', status: 'pending' }
|
||||
];
|
||||
|
||||
this.notifySubscribers({
|
||||
type: 'status_change',
|
||||
itemId,
|
||||
status: 'pending',
|
||||
timestamp: new Date().toISOString(),
|
||||
progress: item.phases,
|
||||
data: { retryCount: item.retryCount }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all queue items
|
||||
*
|
||||
* @returns Array of all queue items
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const items = queueManager.getAll();
|
||||
* console.log(`Queue has ${items.length} items`);
|
||||
* ```
|
||||
*/
|
||||
getAll(): QueueItem[] {
|
||||
return Array.from(this.items.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single item by ID
|
||||
*
|
||||
* @param itemId - ID of item to retrieve
|
||||
* @returns Queue item or undefined if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const item = queueManager.get(itemId);
|
||||
* if (item) {
|
||||
* console.log('Status:', item.status);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
get(itemId: string): QueueItem | undefined {
|
||||
return this.items.get(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to queue updates
|
||||
*
|
||||
* Callback will be called whenever any item is updated.
|
||||
*
|
||||
* @param callback - Function to call on each update
|
||||
* @returns Unsubscribe function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const unsubscribe = queueManager.subscribe((update) => {
|
||||
* console.log('Update:', update.itemId, update.status);
|
||||
* });
|
||||
*
|
||||
* // Later...
|
||||
* unsubscribe();
|
||||
* ```
|
||||
*/
|
||||
subscribe(callback: QueueUpdateCallback): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all subscribers of an update
|
||||
*
|
||||
* Handles errors in individual subscribers to prevent one
|
||||
* bad subscriber from affecting others.
|
||||
*
|
||||
* @param update - Update to broadcast
|
||||
*/
|
||||
private notifySubscribers(update: QueueStatusUpdate): void {
|
||||
for (const callback of this.subscribers) {
|
||||
try {
|
||||
callback(update);
|
||||
} catch (err) {
|
||||
logError('[QueueManager] Subscriber error', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of QueueManager
|
||||
*
|
||||
*
|
||||
* Use this instance throughout the application to ensure
|
||||
* all components interact with the same queue.
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Queue Processor - Orchestrates async processing of queue items
|
||||
*
|
||||
*
|
||||
* Manages concurrent processing of Instagram URLs through three phases:
|
||||
* 1. Extraction - Browser automation to extract text and thumbnail
|
||||
* 2. Parsing - LLM-based recipe extraction
|
||||
* 3. Uploading - Automatic upload to Tandoor (if configured)
|
||||
*
|
||||
*
|
||||
* Architecture: Domain Layer (Hexagonal Architecture)
|
||||
* - Domain Logic: Orchestrates processing workflow
|
||||
* - Uses Ports: extraction.ts, parser.ts, tandoor.ts (secondary adapters)
|
||||
@@ -23,422 +23,424 @@ import type { QueueItem } from './types';
|
||||
|
||||
/**
|
||||
* Queue processor with configurable concurrency
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* - Concurrent processing (default: 2 simultaneous items)
|
||||
* - Three-phase pipeline: extraction → parsing → uploading
|
||||
* - Error classification (recoverable vs non-recoverable)
|
||||
* - Progress tracking via QueueManager
|
||||
* - Automatic start on instantiation
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { queueProcessor } from './QueueProcessor';
|
||||
*
|
||||
*
|
||||
* // Processor auto-starts on import
|
||||
* // Add items to queue and they'll be processed automatically
|
||||
*
|
||||
*
|
||||
* // Stop processing (e.g., for maintenance)
|
||||
* queueProcessor.stop();
|
||||
*
|
||||
*
|
||||
* // Resume processing
|
||||
* queueProcessor.start();
|
||||
* ```
|
||||
*/
|
||||
export class QueueProcessor {
|
||||
/** Whether processor is actively running */
|
||||
private processing = false;
|
||||
|
||||
/** Maximum number of items to process simultaneously */
|
||||
private concurrency = queueConfig.concurrency;
|
||||
|
||||
/** Number of workers currently processing items */
|
||||
private activeWorkers = 0;
|
||||
|
||||
/** Unsubscribe function for queue manager subscription */
|
||||
private unsubscribeFromQueue?: () => void;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to queue updates to process new items immediately
|
||||
this.unsubscribeFromQueue = queueManager.subscribe((update) => {
|
||||
// Trigger processing when new items are enqueued (status_change to 'pending')
|
||||
if (update.type === 'status_change' && update.status === 'pending') {
|
||||
console.log(`[QueueProcessor] New item enqueued: ${update.itemId}, triggering processing`);
|
||||
// Use immediate processing (no timeout) for newly enqueued items
|
||||
setTimeout(() => this.processNextBatch(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing queue
|
||||
*
|
||||
* Begins dequeuing and processing items up to concurrency limit.
|
||||
* Safe to call multiple times - will not start duplicates.
|
||||
*/
|
||||
start(): void {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
console.log(`[QueueProcessor] Started with concurrency ${this.concurrency}`);
|
||||
this.processNextBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop processing queue
|
||||
*
|
||||
* Prevents new items from being dequeued.
|
||||
* Items currently in progress will complete.
|
||||
*/
|
||||
stop(): void {
|
||||
this.processing = false;
|
||||
console.log('[QueueProcessor] Stopped');
|
||||
|
||||
// Cleanup subscription when stopping
|
||||
if (this.unsubscribeFromQueue) {
|
||||
this.unsubscribeFromQueue();
|
||||
this.unsubscribeFromQueue = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process items up to concurrency limit
|
||||
*
|
||||
* Dequeues pending items and starts processing them.
|
||||
* Automatically called recursively to maintain worker pool.
|
||||
*/
|
||||
private async processNextBatch(): Promise<void> {
|
||||
if (!this.processing) return;
|
||||
|
||||
// Start new workers up to concurrency limit
|
||||
while (this.activeWorkers < this.concurrency) {
|
||||
const item = queueManager.dequeue();
|
||||
if (!item) break;
|
||||
|
||||
this.activeWorkers++;
|
||||
console.log(`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
|
||||
|
||||
this.processItem(item)
|
||||
.finally(() => {
|
||||
this.activeWorkers--;
|
||||
console.log(`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`);
|
||||
// Try to process next item immediately
|
||||
setTimeout(() => this.processNextBatch(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Check again after shorter delay if still processing and no active workers
|
||||
if (this.processing && this.activeWorkers === 0) {
|
||||
setTimeout(() => this.processNextBatch(), 100); // Reduced from 1000ms to 100ms
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single queue item through all phases
|
||||
*
|
||||
* Executes three phases sequentially:
|
||||
* 1. Extraction - Extract content from Instagram
|
||||
* 2. Parsing - Parse recipe from extracted text
|
||||
* 3. Uploading - Upload to Tandoor (if configured)
|
||||
*
|
||||
* On success: marks item as 'success'
|
||||
* On error: marks item as 'unhealthy' (recoverable) or 'error' (non-recoverable)
|
||||
*
|
||||
* @param item - Queue item to process
|
||||
*/
|
||||
private async processItem(item: QueueItem): Promise<void> {
|
||||
try {
|
||||
console.log(`[QueueProcessor] Processing ${item.url}`);
|
||||
|
||||
// Phase 1: Extraction
|
||||
await this.extractionPhase(item);
|
||||
|
||||
// Phase 2: Parsing
|
||||
await this.parsingPhase(item);
|
||||
|
||||
// Phase 3: Tandoor Upload (if enabled)
|
||||
await this.uploadPhase(item);
|
||||
|
||||
// Success
|
||||
queueManager.updateStatus(item.id, 'success');
|
||||
console.log(`[QueueProcessor] ✓ Success: ${item.id}`);
|
||||
|
||||
// Send push notification
|
||||
await this.sendPushNotification(item, 'success');
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
const recoverable = this.isRecoverableError(error);
|
||||
|
||||
logError(`[QueueProcessor] ${recoverable ? 'Unhealthy' : 'Error'}: ${item.id}`, error);
|
||||
|
||||
queueManager.updateStatus(item.id, recoverable ? 'unhealthy' : 'error', {
|
||||
error: {
|
||||
phase: item.currentPhase || 'extraction',
|
||||
message: errorMsg,
|
||||
recoverable,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await this.sendPushNotification(item, recoverable ? 'unhealthy' : 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Extract text and thumbnail from Instagram
|
||||
*
|
||||
* Uses browser automation to load Instagram post and extract:
|
||||
* - Recipe text (from caption, comments, etc.)
|
||||
* - Thumbnail image (from meta tags or screenshot)
|
||||
*
|
||||
* Progress events are captured and added to queue item.
|
||||
*
|
||||
* @param item - Queue item being processed
|
||||
* @throws Error if extraction fails
|
||||
*/
|
||||
private async extractionPhase(item: QueueItem): Promise<void> {
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'extraction'
|
||||
});
|
||||
|
||||
const progressCallback = (event: ProgressEvent) => {
|
||||
queueManager.addProgressEvent(item.id, event);
|
||||
};
|
||||
|
||||
console.log(`[QueueProcessor] Extracting: ${item.url}`);
|
||||
const extracted = await extractTextAndThumbnail(item.url, progressCallback);
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'extraction',
|
||||
extractedText: extracted.bodyText,
|
||||
thumbnail: extracted.thumbnail
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] ✓ Extraction complete: ${item.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Parse recipe from extracted text
|
||||
*
|
||||
* Uses LLM to extract structured recipe data:
|
||||
* - Recipe name
|
||||
* - Ingredients with amounts and units
|
||||
* - Instructions/steps
|
||||
* - Servings, times, etc.
|
||||
*
|
||||
* Enriches recipe with metadata (URL, thumbnail).
|
||||
*
|
||||
* @param item - Queue item being processed
|
||||
* @throws Error if parsing fails or no recipe found
|
||||
*/
|
||||
private async parsingPhase(item: QueueItem): Promise<void> {
|
||||
if (!item.extractedText) {
|
||||
throw new Error('No extracted text available for parsing');
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'parsing'
|
||||
});
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Parsing recipe with LLM...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
|
||||
const recipe = await extractRecipe(item.extractedText);
|
||||
|
||||
if (!recipe) {
|
||||
throw new Error('Failed to parse recipe from extracted text');
|
||||
}
|
||||
|
||||
// Enrich recipe with metadata
|
||||
if (recipe.description) {
|
||||
recipe.description += `\n\nLink: ${item.url}`;
|
||||
} else {
|
||||
recipe.description = `Link: ${item.url}`;
|
||||
}
|
||||
|
||||
if (item.thumbnail) {
|
||||
recipe.image = item.thumbnail;
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'parsing',
|
||||
recipe
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] ✓ Parsing complete: ${item.id} - ${recipe.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3: Upload to Tandoor (automatic)
|
||||
*
|
||||
* If Tandoor is configured (TANDOOR_TOKEN env var set):
|
||||
* - Uploads recipe with ingredients and steps
|
||||
* - Attempts to upload thumbnail/image
|
||||
* - Image upload failure is non-fatal (logged but doesn't fail item)
|
||||
*
|
||||
* If Tandoor not configured: skips silently
|
||||
*
|
||||
* @param item - Queue item being processed
|
||||
* @throws Error if Tandoor upload fails
|
||||
*/
|
||||
private async uploadPhase(item: QueueItem): Promise<void> {
|
||||
// Check if Tandoor is enabled
|
||||
if (!queueConfig.tandoor.enabled) {
|
||||
// Skip if Tandoor not configured
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Tandoor not configured, skipping upload',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.recipe) {
|
||||
throw new Error('No recipe available for upload');
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'uploading'
|
||||
});
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Uploading recipe to Tandoor...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
|
||||
|
||||
// Upload recipe
|
||||
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Tandoor upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'uploading',
|
||||
tandoorRecipeId: result.recipeId
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
|
||||
|
||||
// Upload image if available
|
||||
if (result.recipeId && result.imageUrl) {
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Uploading recipe image to Tandoor...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
|
||||
|
||||
if (!imageResult.success) {
|
||||
// Image upload failure is recoverable - log but don't fail
|
||||
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: `Image upload failed: ${imageResult.error}`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Tandoor upload completed',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if error is recoverable
|
||||
*
|
||||
* Recoverable errors (unhealthy):
|
||||
* - Network timeouts
|
||||
* - Connection failures
|
||||
* - Image upload failures
|
||||
* - Thumbnail extraction failures
|
||||
*
|
||||
* Non-recoverable errors (error):
|
||||
* - Invalid URL format
|
||||
* - Authentication failures
|
||||
* - Parsing failures (no recipe found)
|
||||
*
|
||||
* @param error - Error to classify
|
||||
* @returns true if error is recoverable, false otherwise
|
||||
*/
|
||||
private isRecoverableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Recoverable errors
|
||||
const recoverablePatterns = [
|
||||
'timeout',
|
||||
'network',
|
||||
'econnrefused',
|
||||
'enotfound',
|
||||
'image upload failed',
|
||||
'thumbnail',
|
||||
'etimeout',
|
||||
'fetch failed'
|
||||
];
|
||||
|
||||
return recoverablePatterns.some(pattern => message.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Web Push notification for queue item completion
|
||||
*
|
||||
* Sends appropriate notification based on processing status:
|
||||
* - success: Recipe extraction complete with details
|
||||
* - error/unhealthy: Extraction failed with retry option
|
||||
*
|
||||
* @param item - Queue item that completed
|
||||
* @param status - Completion status (success, unhealthy, error)
|
||||
*/
|
||||
private async sendPushNotification(
|
||||
item: QueueItem,
|
||||
status: 'success' | 'unhealthy' | 'error'
|
||||
): Promise<void> {
|
||||
try {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
await pushNotificationService.notifySuccess(
|
||||
item.id,
|
||||
item.results?.recipe?.name,
|
||||
item.results?.tandoorUrl
|
||||
);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
case 'unhealthy':
|
||||
const errorMessage = item.error?.message || 'Processing failed';
|
||||
await pushNotificationService.notifyError(item.id, errorMessage);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[QueueProcessor] Unknown status for push notification: ${status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('[QueueProcessor] Failed to send push notification', error);
|
||||
// Don't let notification failures break processing
|
||||
}
|
||||
}
|
||||
/** Whether processor is actively running */
|
||||
private processing = false;
|
||||
|
||||
/** Maximum number of items to process simultaneously */
|
||||
private concurrency = queueConfig.concurrency;
|
||||
|
||||
/** Number of workers currently processing items */
|
||||
private activeWorkers = 0;
|
||||
|
||||
/** Unsubscribe function for queue manager subscription */
|
||||
private unsubscribeFromQueue?: () => void;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to queue updates to process new items immediately
|
||||
this.unsubscribeFromQueue = queueManager.subscribe((update) => {
|
||||
// Trigger processing when new items are enqueued (status_change to 'pending')
|
||||
if (update.type === 'status_change' && update.status === 'pending') {
|
||||
console.log(`[QueueProcessor] New item enqueued: ${update.itemId}, triggering processing`);
|
||||
// Use immediate processing (no timeout) for newly enqueued items
|
||||
setTimeout(() => this.processNextBatch(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing queue
|
||||
*
|
||||
* Begins dequeuing and processing items up to concurrency limit.
|
||||
* Safe to call multiple times - will not start duplicates.
|
||||
*/
|
||||
start(): void {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
console.log(`[QueueProcessor] Started with concurrency ${this.concurrency}`);
|
||||
this.processNextBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop processing queue
|
||||
*
|
||||
* Prevents new items from being dequeued.
|
||||
* Items currently in progress will complete.
|
||||
*/
|
||||
stop(): void {
|
||||
this.processing = false;
|
||||
console.log('[QueueProcessor] Stopped');
|
||||
|
||||
// Cleanup subscription when stopping
|
||||
if (this.unsubscribeFromQueue) {
|
||||
this.unsubscribeFromQueue();
|
||||
this.unsubscribeFromQueue = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process items up to concurrency limit
|
||||
*
|
||||
* Dequeues pending items and starts processing them.
|
||||
* Automatically called recursively to maintain worker pool.
|
||||
*/
|
||||
private async processNextBatch(): Promise<void> {
|
||||
if (!this.processing) return;
|
||||
|
||||
// Start new workers up to concurrency limit
|
||||
while (this.activeWorkers < this.concurrency) {
|
||||
const item = queueManager.dequeue();
|
||||
if (!item) break;
|
||||
|
||||
this.activeWorkers++;
|
||||
console.log(
|
||||
`[QueueProcessor] Starting item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
|
||||
);
|
||||
|
||||
this.processItem(item).finally(() => {
|
||||
this.activeWorkers--;
|
||||
console.log(
|
||||
`[QueueProcessor] Finished item ${item.id} (${this.activeWorkers}/${this.concurrency} active)`
|
||||
);
|
||||
// Try to process next item immediately
|
||||
setTimeout(() => this.processNextBatch(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Check again after shorter delay if still processing and no active workers
|
||||
if (this.processing && this.activeWorkers === 0) {
|
||||
setTimeout(() => this.processNextBatch(), 100); // Reduced from 1000ms to 100ms
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single queue item through all phases
|
||||
*
|
||||
* Executes three phases sequentially:
|
||||
* 1. Extraction - Extract content from Instagram
|
||||
* 2. Parsing - Parse recipe from extracted text
|
||||
* 3. Uploading - Upload to Tandoor (if configured)
|
||||
*
|
||||
* On success: marks item as 'success'
|
||||
* On error: marks item as 'unhealthy' (recoverable) or 'error' (non-recoverable)
|
||||
*
|
||||
* @param item - Queue item to process
|
||||
*/
|
||||
private async processItem(item: QueueItem): Promise<void> {
|
||||
try {
|
||||
console.log(`[QueueProcessor] Processing ${item.url}`);
|
||||
|
||||
// Phase 1: Extraction
|
||||
await this.extractionPhase(item);
|
||||
|
||||
// Phase 2: Parsing
|
||||
await this.parsingPhase(item);
|
||||
|
||||
// Phase 3: Tandoor Upload (if enabled)
|
||||
await this.uploadPhase(item);
|
||||
|
||||
// Success
|
||||
queueManager.updateStatus(item.id, 'success');
|
||||
console.log(`[QueueProcessor] ✓ Success: ${item.id}`);
|
||||
|
||||
// Send push notification
|
||||
await this.sendPushNotification(item, 'success');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
const recoverable = this.isRecoverableError(error);
|
||||
|
||||
logError(`[QueueProcessor] ${recoverable ? 'Unhealthy' : 'Error'}: ${item.id}`, error);
|
||||
|
||||
queueManager.updateStatus(item.id, recoverable ? 'unhealthy' : 'error', {
|
||||
error: {
|
||||
phase: item.currentPhase || 'extraction',
|
||||
message: errorMsg,
|
||||
recoverable,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
await this.sendPushNotification(item, recoverable ? 'unhealthy' : 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Extract text and thumbnail from Instagram
|
||||
*
|
||||
* Uses browser automation to load Instagram post and extract:
|
||||
* - Recipe text (from caption, comments, etc.)
|
||||
* - Thumbnail image (from meta tags or screenshot)
|
||||
*
|
||||
* Progress events are captured and added to queue item.
|
||||
*
|
||||
* @param item - Queue item being processed
|
||||
* @throws Error if extraction fails
|
||||
*/
|
||||
private async extractionPhase(item: QueueItem): Promise<void> {
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'extraction'
|
||||
});
|
||||
|
||||
const progressCallback = (event: ProgressEvent) => {
|
||||
queueManager.addProgressEvent(item.id, event);
|
||||
};
|
||||
|
||||
console.log(`[QueueProcessor] Extracting: ${item.url}`);
|
||||
const extracted = await extractTextAndThumbnail(item.url, progressCallback);
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'extraction',
|
||||
extractedText: extracted.bodyText,
|
||||
thumbnail: extracted.thumbnail
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] ✓ Extraction complete: ${item.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Parse recipe from extracted text
|
||||
*
|
||||
* Uses LLM to extract structured recipe data:
|
||||
* - Recipe name
|
||||
* - Ingredients with amounts and units
|
||||
* - Instructions/steps
|
||||
* - Servings, times, etc.
|
||||
*
|
||||
* Enriches recipe with metadata (URL, thumbnail).
|
||||
*
|
||||
* @param item - Queue item being processed
|
||||
* @throws Error if parsing fails or no recipe found
|
||||
*/
|
||||
private async parsingPhase(item: QueueItem): Promise<void> {
|
||||
if (!item.extractedText) {
|
||||
throw new Error('No extracted text available for parsing');
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'parsing'
|
||||
});
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Parsing recipe with LLM...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
|
||||
const recipe = await extractRecipe(item.extractedText);
|
||||
|
||||
if (!recipe) {
|
||||
throw new Error('Failed to parse recipe from extracted text');
|
||||
}
|
||||
|
||||
// Enrich recipe with metadata
|
||||
if (recipe.description) {
|
||||
recipe.description += `\n\nLink: ${item.url}`;
|
||||
} else {
|
||||
recipe.description = `Link: ${item.url}`;
|
||||
}
|
||||
|
||||
if (item.thumbnail) {
|
||||
recipe.image = item.thumbnail;
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'parsing',
|
||||
recipe
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] ✓ Parsing complete: ${item.id} - ${recipe.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3: Upload to Tandoor (automatic)
|
||||
*
|
||||
* If Tandoor is configured (TANDOOR_TOKEN env var set):
|
||||
* - Uploads recipe with ingredients and steps
|
||||
* - Attempts to upload thumbnail/image
|
||||
* - Image upload failure is non-fatal (logged but doesn't fail item)
|
||||
*
|
||||
* If Tandoor not configured: skips silently
|
||||
*
|
||||
* @param item - Queue item being processed
|
||||
* @throws Error if Tandoor upload fails
|
||||
*/
|
||||
private async uploadPhase(item: QueueItem): Promise<void> {
|
||||
// Check if Tandoor is enabled
|
||||
if (!queueConfig.tandoor.enabled) {
|
||||
// Skip if Tandoor not configured
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Tandoor not configured, skipping upload',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.recipe) {
|
||||
throw new Error('No recipe available for upload');
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'uploading'
|
||||
});
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Uploading recipe to Tandoor...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
|
||||
|
||||
// Upload recipe
|
||||
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Tandoor upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'uploading',
|
||||
tandoorRecipeId: result.recipeId
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
|
||||
|
||||
// Upload image if available
|
||||
if (result.recipeId && result.imageUrl) {
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Uploading recipe image to Tandoor...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
|
||||
|
||||
if (!imageResult.success) {
|
||||
// Image upload failure is recoverable - log but don't fail
|
||||
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: `Image upload failed: ${imageResult.error}`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Tandoor upload completed',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if error is recoverable
|
||||
*
|
||||
* Recoverable errors (unhealthy):
|
||||
* - Network timeouts
|
||||
* - Connection failures
|
||||
* - Image upload failures
|
||||
* - Thumbnail extraction failures
|
||||
*
|
||||
* Non-recoverable errors (error):
|
||||
* - Invalid URL format
|
||||
* - Authentication failures
|
||||
* - Parsing failures (no recipe found)
|
||||
*
|
||||
* @param error - Error to classify
|
||||
* @returns true if error is recoverable, false otherwise
|
||||
*/
|
||||
private isRecoverableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Recoverable errors
|
||||
const recoverablePatterns = [
|
||||
'timeout',
|
||||
'network',
|
||||
'econnrefused',
|
||||
'enotfound',
|
||||
'image upload failed',
|
||||
'thumbnail',
|
||||
'etimeout',
|
||||
'fetch failed'
|
||||
];
|
||||
|
||||
return recoverablePatterns.some((pattern) => message.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Web Push notification for queue item completion
|
||||
*
|
||||
* Sends appropriate notification based on processing status:
|
||||
* - success: Recipe extraction complete with details
|
||||
* - error/unhealthy: Extraction failed with retry option
|
||||
*
|
||||
* @param item - Queue item that completed
|
||||
* @param status - Completion status (success, unhealthy, error)
|
||||
*/
|
||||
private async sendPushNotification(
|
||||
item: QueueItem,
|
||||
status: 'success' | 'unhealthy' | 'error'
|
||||
): Promise<void> {
|
||||
try {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
await pushNotificationService.notifySuccess(
|
||||
item.id,
|
||||
item.results?.recipe?.name,
|
||||
item.results?.tandoorUrl
|
||||
);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
case 'unhealthy':
|
||||
const errorMessage = item.error?.message || 'Processing failed';
|
||||
await pushNotificationService.notifyError(item.id, errorMessage);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[QueueProcessor] Unknown status for push notification: ${status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('[QueueProcessor] Failed to send push notification', error);
|
||||
// Don't let notification failures break processing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of QueueProcessor
|
||||
*
|
||||
*
|
||||
* Auto-starts on module import to begin processing queue.
|
||||
*/
|
||||
export const queueProcessor = new QueueProcessor();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private';
|
||||
/**
|
||||
* Server-side configuration for the async queue system
|
||||
* Uses SvelteKit's $env/dynamic/private for runtime environment access
|
||||
*
|
||||
*
|
||||
* Environment Variables:
|
||||
* - QUEUE_CONCURRENCY: Number of items to process concurrently (default: 2)
|
||||
* - QUEUE_MAX_RETRIES: Maximum retry attempts for failed items (default: 3)
|
||||
@@ -29,7 +29,9 @@ export const queueConfig = {
|
||||
|
||||
/** Web Push notification settings */
|
||||
push: {
|
||||
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
||||
vapidPublicKey:
|
||||
env.VAPID_PUBLIC_KEY ||
|
||||
'BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-lq_0HykLCdMpYj8d5joavWdxQ',
|
||||
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680',
|
||||
vapidEmail: env.VAPID_EMAIL || 'mailto:admin@example.com'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Type definitions for the async in-memory processing queue
|
||||
*
|
||||
*
|
||||
* This module defines the core data structures for queue items,
|
||||
* status updates, and callbacks used throughout the queue system.
|
||||
*/
|
||||
@@ -15,12 +15,7 @@ import type { ProgressEvent } from '$lib/server/extraction';
|
||||
* - unhealthy: Recoverable error occurred, can be retried
|
||||
* - error: Non-recoverable error occurred
|
||||
*/
|
||||
export type QueueItemStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'success'
|
||||
| 'unhealthy'
|
||||
| 'error';
|
||||
export type QueueItemStatus = 'pending' | 'in_progress' | 'success' | 'unhealthy' | 'error';
|
||||
|
||||
/**
|
||||
* Processing phases for queue items
|
||||
@@ -28,26 +23,23 @@ export type QueueItemStatus =
|
||||
* - parsing: Parsing recipe from extracted text
|
||||
* - uploading: Uploading recipe to Tandoor
|
||||
*/
|
||||
export type ProcessingPhase =
|
||||
| 'extraction'
|
||||
| 'parsing'
|
||||
| 'uploading';
|
||||
export type ProcessingPhase = 'extraction' | 'parsing' | 'uploading';
|
||||
|
||||
/**
|
||||
* Phase progress information
|
||||
* Tracks the status of each processing phase
|
||||
*/
|
||||
export interface PhaseProgress {
|
||||
/** Name of the phase */
|
||||
name: ProcessingPhase;
|
||||
/** Current status of this phase */
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'error';
|
||||
/** When phase started processing (ISO 8601 string) */
|
||||
startedAt?: string;
|
||||
/** When phase completed (ISO 8601 string) */
|
||||
completedAt?: string;
|
||||
/** Error message if phase failed */
|
||||
error?: string;
|
||||
/** Name of the phase */
|
||||
name: ProcessingPhase;
|
||||
/** Current status of this phase */
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'error';
|
||||
/** When phase started processing (ISO 8601 string) */
|
||||
startedAt?: string;
|
||||
/** When phase completed (ISO 8601 string) */
|
||||
completedAt?: string;
|
||||
/** Error message if phase failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,135 +47,135 @@ export interface PhaseProgress {
|
||||
* Contains all outputs from the processing pipeline
|
||||
*/
|
||||
export interface ProcessingResults {
|
||||
/** Extracted text from Instagram */
|
||||
extractedText?: string;
|
||||
/** Thumbnail URL or data URL */
|
||||
thumbnail?: string | null;
|
||||
/** Parsed recipe object */
|
||||
recipe?: any;
|
||||
/** Tandoor recipe ID */
|
||||
tandoorRecipeId?: number;
|
||||
/** Tandoor recipe URL (constructed from ID) */
|
||||
tandoorUrl?: string;
|
||||
/** Extracted text from Instagram */
|
||||
extractedText?: string;
|
||||
/** Thumbnail URL or data URL */
|
||||
thumbnail?: string | null;
|
||||
/** Parsed recipe object */
|
||||
recipe?: any;
|
||||
/** Tandoor recipe ID */
|
||||
tandoorRecipeId?: number;
|
||||
/** Tandoor recipe URL (constructed from ID) */
|
||||
tandoorUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue item representing a single Instagram URL processing job
|
||||
*/
|
||||
export interface QueueItem {
|
||||
/** Unique identifier (UUID) */
|
||||
id: string;
|
||||
|
||||
/** Instagram URL to process */
|
||||
url: string;
|
||||
|
||||
/** Current status of the item */
|
||||
status: QueueItemStatus;
|
||||
|
||||
// Phase tracking
|
||||
/** Current processing phase (only set when status is in_progress) */
|
||||
currentPhase?: ProcessingPhase;
|
||||
|
||||
/** Array of all phases with their progress status */
|
||||
phases: PhaseProgress[];
|
||||
|
||||
// Timestamps
|
||||
/** When item was added to queue (ISO 8601 string) */
|
||||
enqueuedAt: string;
|
||||
|
||||
/** Alias for enqueuedAt (frontend uses this) */
|
||||
createdAt: string;
|
||||
|
||||
/** When processing started (ISO 8601 string) */
|
||||
startedAt?: string;
|
||||
|
||||
/** When processing completed (ISO 8601 string) */
|
||||
completedAt?: string;
|
||||
|
||||
/** Last update timestamp (ISO 8601 string) */
|
||||
updatedAt?: string;
|
||||
|
||||
// Results - wrapped in results object
|
||||
/** Processing results container */
|
||||
results?: ProcessingResults;
|
||||
|
||||
// Legacy direct properties (kept for transition period)
|
||||
/** @deprecated Use results.extractedText instead */
|
||||
extractedText?: string;
|
||||
|
||||
/** @deprecated Use results.thumbnail instead */
|
||||
thumbnail?: string | null;
|
||||
|
||||
/** @deprecated Use results.recipe instead */
|
||||
recipe?: any;
|
||||
|
||||
/** @deprecated Use results.tandoorRecipeId instead */
|
||||
tandoorRecipeId?: number;
|
||||
|
||||
// Progress tracking
|
||||
/** User-facing log messages */
|
||||
logs: string[];
|
||||
|
||||
/** All SSE progress events received */
|
||||
progressEvents: ProgressEvent[];
|
||||
|
||||
// Error handling
|
||||
/** Error details if processing failed */
|
||||
error?: {
|
||||
/** Phase where error occurred */
|
||||
phase: ProcessingPhase;
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Whether error is recoverable (can retry) */
|
||||
recoverable: boolean;
|
||||
/** When error occurred (ISO 8601 string) */
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Retry tracking
|
||||
/** Number of times this item has been retried */
|
||||
retryCount: number;
|
||||
|
||||
/** Maximum number of retries allowed */
|
||||
maxRetries: number;
|
||||
/** Unique identifier (UUID) */
|
||||
id: string;
|
||||
|
||||
/** Instagram URL to process */
|
||||
url: string;
|
||||
|
||||
/** Current status of the item */
|
||||
status: QueueItemStatus;
|
||||
|
||||
// Phase tracking
|
||||
/** Current processing phase (only set when status is in_progress) */
|
||||
currentPhase?: ProcessingPhase;
|
||||
|
||||
/** Array of all phases with their progress status */
|
||||
phases: PhaseProgress[];
|
||||
|
||||
// Timestamps
|
||||
/** When item was added to queue (ISO 8601 string) */
|
||||
enqueuedAt: string;
|
||||
|
||||
/** Alias for enqueuedAt (frontend uses this) */
|
||||
createdAt: string;
|
||||
|
||||
/** When processing started (ISO 8601 string) */
|
||||
startedAt?: string;
|
||||
|
||||
/** When processing completed (ISO 8601 string) */
|
||||
completedAt?: string;
|
||||
|
||||
/** Last update timestamp (ISO 8601 string) */
|
||||
updatedAt?: string;
|
||||
|
||||
// Results - wrapped in results object
|
||||
/** Processing results container */
|
||||
results?: ProcessingResults;
|
||||
|
||||
// Legacy direct properties (kept for transition period)
|
||||
/** @deprecated Use results.extractedText instead */
|
||||
extractedText?: string;
|
||||
|
||||
/** @deprecated Use results.thumbnail instead */
|
||||
thumbnail?: string | null;
|
||||
|
||||
/** @deprecated Use results.recipe instead */
|
||||
recipe?: any;
|
||||
|
||||
/** @deprecated Use results.tandoorRecipeId instead */
|
||||
tandoorRecipeId?: number;
|
||||
|
||||
// Progress tracking
|
||||
/** User-facing log messages */
|
||||
logs: string[];
|
||||
|
||||
/** All SSE progress events received */
|
||||
progressEvents: ProgressEvent[];
|
||||
|
||||
// Error handling
|
||||
/** Error details if processing failed */
|
||||
error?: {
|
||||
/** Phase where error occurred */
|
||||
phase: ProcessingPhase;
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Whether error is recoverable (can retry) */
|
||||
recoverable: boolean;
|
||||
/** When error occurred (ISO 8601 string) */
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Retry tracking
|
||||
/** Number of times this item has been retried */
|
||||
retryCount: number;
|
||||
|
||||
/** Maximum number of retries allowed */
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification sent to queue subscribers
|
||||
*/
|
||||
export interface QueueStatusUpdate {
|
||||
/** Type of update */
|
||||
type: 'status_change' | 'progress' | 'phase_complete';
|
||||
|
||||
/** ID of the item that was updated */
|
||||
itemId: string;
|
||||
|
||||
/** New status of the item */
|
||||
status: QueueItemStatus;
|
||||
|
||||
/** When update occurred (ISO 8601 string) */
|
||||
timestamp: string;
|
||||
|
||||
/** URL of the item */
|
||||
url?: string;
|
||||
|
||||
// Phase information
|
||||
/** Current phase (if status is in_progress) */
|
||||
phase?: ProcessingPhase;
|
||||
|
||||
/** Full phase progress array */
|
||||
progress?: PhaseProgress[];
|
||||
|
||||
// Results
|
||||
/** Processing results object */
|
||||
results?: ProcessingResults;
|
||||
|
||||
// Error
|
||||
/** Error information */
|
||||
error?: any;
|
||||
|
||||
/** Additional data related to the update (legacy) */
|
||||
data?: any;
|
||||
/** Type of update */
|
||||
type: 'status_change' | 'progress' | 'phase_complete';
|
||||
|
||||
/** ID of the item that was updated */
|
||||
itemId: string;
|
||||
|
||||
/** New status of the item */
|
||||
status: QueueItemStatus;
|
||||
|
||||
/** When update occurred (ISO 8601 string) */
|
||||
timestamp: string;
|
||||
|
||||
/** URL of the item */
|
||||
url?: string;
|
||||
|
||||
// Phase information
|
||||
/** Current phase (if status is in_progress) */
|
||||
phase?: ProcessingPhase;
|
||||
|
||||
/** Full phase progress array */
|
||||
progress?: PhaseProgress[];
|
||||
|
||||
// Results
|
||||
/** Processing results object */
|
||||
results?: ProcessingResults;
|
||||
|
||||
// Error
|
||||
/** Error information */
|
||||
error?: any;
|
||||
|
||||
/** Additional data related to the update (legacy) */
|
||||
data?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,194 +1,202 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getBrowser } from './browser';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { logError } from './utils/logger';
|
||||
|
||||
export interface SchedulerConfig {
|
||||
enabled: boolean;
|
||||
intervalMinutes: number;
|
||||
}
|
||||
|
||||
interface SchedulerState {
|
||||
intervalId: NodeJS.Timeout | null;
|
||||
lastRenewalTime: number | null;
|
||||
isRenewing: boolean;
|
||||
}
|
||||
|
||||
const state: SchedulerState = {
|
||||
intervalId: null,
|
||||
lastRenewalTime: null,
|
||||
isRenewing: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Get scheduler configuration from environment variables
|
||||
*/
|
||||
function getConfig(): SchedulerConfig {
|
||||
const enabled = env.AUTH_SCHEDULER_ENABLED === 'true';
|
||||
let intervalMinutes = parseInt(env.AUTH_SCHEDULER_INTERVAL_MINUTES || '720', 10);
|
||||
|
||||
if (isNaN(intervalMinutes) || intervalMinutes < 5) {
|
||||
console.warn(
|
||||
`[Scheduler] Invalid or too short interval '${env.AUTH_SCHEDULER_INTERVAL_MINUTES}'. Defaulting to 720 minutes.`
|
||||
);
|
||||
intervalMinutes = 720;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
intervalMinutes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve authentication storage path
|
||||
*/
|
||||
function resolveAuthPath(): string {
|
||||
const authPathDocker = '/app/secrets/auth.json';
|
||||
const authPathLocal = './secrets/auth.json';
|
||||
|
||||
if (fs.existsSync(authPathDocker)) {
|
||||
return authPathDocker;
|
||||
}
|
||||
|
||||
if (fs.existsSync(authPathLocal)) {
|
||||
return authPathLocal;
|
||||
}
|
||||
|
||||
// Default to local path if neither exists yet
|
||||
return authPathLocal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew Instagram authentication by loading existing auth and refreshing the session
|
||||
* Inspired by gen-auth.js - reuses existing stored credentials without manual input
|
||||
*/
|
||||
async function renewInstagramAuth(): Promise<boolean> {
|
||||
if (state.isRenewing) {
|
||||
console.log('[Scheduler] Auth renewal already in progress, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
const authPath = resolveAuthPath();
|
||||
|
||||
if (!fs.existsSync(authPath)) {
|
||||
console.warn('[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.');
|
||||
return false;
|
||||
}
|
||||
|
||||
state.isRenewing = true;
|
||||
|
||||
let context = null;
|
||||
let page = null;
|
||||
|
||||
try {
|
||||
console.log('[Scheduler] Starting Instagram authentication renewal...');
|
||||
console.log(`[Scheduler] Loading existing auth from: ${authPath}`);
|
||||
|
||||
const browser = await getBrowser();
|
||||
// Load existing authentication state
|
||||
context = await browser.newContext({ storageState: authPath });
|
||||
page = await context.newPage();
|
||||
|
||||
// Navigate to Instagram homepage - the existing auth will be used automatically
|
||||
await page.goto('https://www.instagram.com/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Wait for the "Home" icon to appear (indicates successful login)
|
||||
try {
|
||||
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 30000 });
|
||||
console.log('[Scheduler] Successfully authenticated with Instagram');
|
||||
} catch (e) {
|
||||
logError('[Scheduler] Home icon not found - session may be expired or invalid', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save the refreshed authentication state
|
||||
const authDir = path.dirname(authPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Update auth.json with refreshed session
|
||||
await context.storageState({ path: authPath });
|
||||
|
||||
state.lastRenewalTime = Date.now();
|
||||
console.log(`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`);
|
||||
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logError('[Scheduler] Instagram authentication renewal failed', error);
|
||||
return false;
|
||||
} finally {
|
||||
if (page) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
if (context) {
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
state.isRenewing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the authentication renewal scheduler
|
||||
*/
|
||||
export async function startScheduler(): Promise<void> {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
console.log('[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.intervalId !== null) {
|
||||
console.warn('[Scheduler] Scheduler is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = config.intervalMinutes * 60 * 1000;
|
||||
|
||||
console.log(`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`);
|
||||
|
||||
// Schedule periodic renewals
|
||||
state.intervalId = setInterval(async () => {
|
||||
await renewInstagramAuth();
|
||||
}, intervalMs);
|
||||
|
||||
// Ensure interval is not blocking (set it as unreferenceable so it doesn't keep the process alive)
|
||||
if (state.intervalId.unref) {
|
||||
state.intervalId.unref();
|
||||
}
|
||||
|
||||
// Optional: Perform initial renewal on startup (uncomment to enable)
|
||||
// await renewInstagramAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the authentication renewal scheduler
|
||||
*/
|
||||
export async function stopScheduler(): Promise<void> {
|
||||
if (state.intervalId === null) {
|
||||
console.log('[Scheduler] Scheduler is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Scheduler] Stopping authentication scheduler...');
|
||||
clearInterval(state.intervalId);
|
||||
state.intervalId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status information
|
||||
*/
|
||||
export function getSchedulerStatus() {
|
||||
return {
|
||||
running: state.intervalId !== null,
|
||||
lastRenewalTime: state.lastRenewalTime ? new Date(state.lastRenewalTime).toISOString() : null,
|
||||
isRenewing: state.isRenewing,
|
||||
config: getConfig()
|
||||
};
|
||||
}
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getBrowser } from './browser';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { logError } from './utils/logger';
|
||||
|
||||
export interface SchedulerConfig {
|
||||
enabled: boolean;
|
||||
intervalMinutes: number;
|
||||
}
|
||||
|
||||
interface SchedulerState {
|
||||
intervalId: NodeJS.Timeout | null;
|
||||
lastRenewalTime: number | null;
|
||||
isRenewing: boolean;
|
||||
}
|
||||
|
||||
const state: SchedulerState = {
|
||||
intervalId: null,
|
||||
lastRenewalTime: null,
|
||||
isRenewing: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Get scheduler configuration from environment variables
|
||||
*/
|
||||
function getConfig(): SchedulerConfig {
|
||||
const enabled = env.AUTH_SCHEDULER_ENABLED === 'true';
|
||||
let intervalMinutes = parseInt(env.AUTH_SCHEDULER_INTERVAL_MINUTES || '720', 10);
|
||||
|
||||
if (isNaN(intervalMinutes) || intervalMinutes < 5) {
|
||||
console.warn(
|
||||
`[Scheduler] Invalid or too short interval '${env.AUTH_SCHEDULER_INTERVAL_MINUTES}'. Defaulting to 720 minutes.`
|
||||
);
|
||||
intervalMinutes = 720;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
intervalMinutes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve authentication storage path
|
||||
*/
|
||||
function resolveAuthPath(): string {
|
||||
const authPathDocker = '/app/secrets/auth.json';
|
||||
const authPathLocal = './secrets/auth.json';
|
||||
|
||||
if (fs.existsSync(authPathDocker)) {
|
||||
return authPathDocker;
|
||||
}
|
||||
|
||||
if (fs.existsSync(authPathLocal)) {
|
||||
return authPathLocal;
|
||||
}
|
||||
|
||||
// Default to local path if neither exists yet
|
||||
return authPathLocal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew Instagram authentication by loading existing auth and refreshing the session
|
||||
* Inspired by gen-auth.js - reuses existing stored credentials without manual input
|
||||
*/
|
||||
async function renewInstagramAuth(): Promise<boolean> {
|
||||
if (state.isRenewing) {
|
||||
console.log('[Scheduler] Auth renewal already in progress, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
const authPath = resolveAuthPath();
|
||||
|
||||
if (!fs.existsSync(authPath)) {
|
||||
console.warn(
|
||||
'[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
state.isRenewing = true;
|
||||
|
||||
let context = null;
|
||||
let page = null;
|
||||
|
||||
try {
|
||||
console.log('[Scheduler] Starting Instagram authentication renewal...');
|
||||
console.log(`[Scheduler] Loading existing auth from: ${authPath}`);
|
||||
|
||||
const browser = await getBrowser();
|
||||
// Load existing authentication state
|
||||
context = await browser.newContext({ storageState: authPath });
|
||||
page = await context.newPage();
|
||||
|
||||
// Navigate to Instagram homepage - the existing auth will be used automatically
|
||||
await page.goto('https://www.instagram.com/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Wait for the "Home" icon to appear (indicates successful login)
|
||||
try {
|
||||
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 30000 });
|
||||
console.log('[Scheduler] Successfully authenticated with Instagram');
|
||||
} catch (e) {
|
||||
logError('[Scheduler] Home icon not found - session may be expired or invalid', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save the refreshed authentication state
|
||||
const authDir = path.dirname(authPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Update auth.json with refreshed session
|
||||
await context.storageState({ path: authPath });
|
||||
|
||||
state.lastRenewalTime = Date.now();
|
||||
console.log(
|
||||
`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`
|
||||
);
|
||||
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logError('[Scheduler] Instagram authentication renewal failed', error);
|
||||
return false;
|
||||
} finally {
|
||||
if (page) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
if (context) {
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
state.isRenewing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the authentication renewal scheduler
|
||||
*/
|
||||
export async function startScheduler(): Promise<void> {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
console.log(
|
||||
'[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.intervalId !== null) {
|
||||
console.warn('[Scheduler] Scheduler is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = config.intervalMinutes * 60 * 1000;
|
||||
|
||||
console.log(
|
||||
`[Scheduler] Starting authentication scheduler with ${config.intervalMinutes}min interval`
|
||||
);
|
||||
|
||||
// Schedule periodic renewals
|
||||
state.intervalId = setInterval(async () => {
|
||||
await renewInstagramAuth();
|
||||
}, intervalMs);
|
||||
|
||||
// Ensure interval is not blocking (set it as unreferenceable so it doesn't keep the process alive)
|
||||
if (state.intervalId.unref) {
|
||||
state.intervalId.unref();
|
||||
}
|
||||
|
||||
// Optional: Perform initial renewal on startup (uncomment to enable)
|
||||
// await renewInstagramAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the authentication renewal scheduler
|
||||
*/
|
||||
export async function stopScheduler(): Promise<void> {
|
||||
if (state.intervalId === null) {
|
||||
console.log('[Scheduler] Scheduler is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Scheduler] Stopping authentication scheduler...');
|
||||
clearInterval(state.intervalId);
|
||||
state.intervalId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status information
|
||||
*/
|
||||
export function getSchedulerStatus() {
|
||||
return {
|
||||
running: state.intervalId !== null,
|
||||
lastRenewalTime: state.lastRenewalTime ? new Date(state.lastRenewalTime).toISOString() : null,
|
||||
isRenewing: state.isRenewing,
|
||||
config: getConfig()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
/**
|
||||
* Server-side environment configuration for Tandoor integration
|
||||
* These variables should be set in your .env file or as environment variables
|
||||
*/
|
||||
|
||||
export const tandoorConfig = {
|
||||
enabled: env.TANDOOR_ENABLED === 'true',
|
||||
serverUrl: (env.TANDOOR_SERVER_URL || '').replace(/\/$/, ''),
|
||||
space: env.TANDOOR_SPACE ? parseInt(env.TANDOOR_SPACE, 10) : 1,
|
||||
token: env.TANDOOR_TOKEN || null
|
||||
};
|
||||
import { env } from '$env/dynamic/private';
|
||||
/**
|
||||
* Server-side environment configuration for Tandoor integration
|
||||
* These variables should be set in your .env file or as environment variables
|
||||
*/
|
||||
|
||||
export const tandoorConfig = {
|
||||
enabled: env.TANDOOR_ENABLED === 'true',
|
||||
serverUrl: (env.TANDOOR_SERVER_URL || '').replace(/\/$/, ''),
|
||||
space: env.TANDOOR_SPACE ? parseInt(env.TANDOOR_SPACE, 10) : 1,
|
||||
token: env.TANDOOR_TOKEN || null
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Logging Utilities
|
||||
*
|
||||
*
|
||||
* Provides error serialization and structured logging utilities to prevent
|
||||
* [object Object] logs in production. All functions handle circular references
|
||||
* and properly serialize Error objects with their properties.
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* - Error serialization with stack traces
|
||||
* - Circular reference detection and handling
|
||||
@@ -15,10 +15,10 @@
|
||||
/**
|
||||
* Serializes an error object to a JSON string.
|
||||
* Handles both Error instances and plain objects.
|
||||
*
|
||||
*
|
||||
* @param error - Error object or unknown value to serialize
|
||||
* @returns JSON string representation of the error
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const err = new Error('Something went wrong');
|
||||
@@ -27,34 +27,34 @@
|
||||
* ```
|
||||
*/
|
||||
export function serializeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const errorObject: Record<string, any> = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
};
|
||||
|
||||
// Add custom properties from the error object
|
||||
for (const key of Object.keys(error)) {
|
||||
if (!(key in errorObject)) {
|
||||
errorObject[key] = (error as any)[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(errorObject, null, 2);
|
||||
}
|
||||
|
||||
return JSON.stringify(error, null, 2);
|
||||
if (error instanceof Error) {
|
||||
const errorObject: Record<string, any> = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
};
|
||||
|
||||
// Add custom properties from the error object
|
||||
for (const key of Object.keys(error)) {
|
||||
if (!(key in errorObject)) {
|
||||
errorObject[key] = (error as any)[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(errorObject, null, 2);
|
||||
}
|
||||
|
||||
return JSON.stringify(error, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an object to a JSON string with circular reference handling.
|
||||
* Prevents "Converting circular structure to JSON" errors.
|
||||
*
|
||||
*
|
||||
* @param obj - Object to serialize
|
||||
* @param maxDepth - Maximum depth for nested objects (default: 10)
|
||||
* @returns JSON string representation of the object
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const circular: any = { a: 1 };
|
||||
@@ -64,28 +64,28 @@ export function serializeError(error: unknown): string {
|
||||
* ```
|
||||
*/
|
||||
export function serializeObject(obj: unknown, maxDepth: number = 10): string {
|
||||
const seen = new WeakSet();
|
||||
|
||||
const replacer = (key: string, value: any): any => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return JSON.stringify(obj, replacer, 2);
|
||||
const seen = new WeakSet();
|
||||
|
||||
const replacer = (key: string, value: any): any => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return JSON.stringify(obj, replacer, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error to console.error with proper serialization.
|
||||
* Convenience wrapper around serializeError().
|
||||
*
|
||||
*
|
||||
* @param prefix - Log prefix (e.g., '[ComponentName]')
|
||||
* @param error - Error object or unknown value to log
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
@@ -96,23 +96,23 @@ export function serializeObject(obj: unknown, maxDepth: number = 10): string {
|
||||
* ```
|
||||
*/
|
||||
export function logError(prefix: string, error: unknown): void {
|
||||
if (error instanceof Error) {
|
||||
console.error(prefix, error.message);
|
||||
if (error.stack) {
|
||||
console.error('Stack:', error.stack);
|
||||
}
|
||||
} else {
|
||||
console.error(prefix, serializeError(error));
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
console.error(prefix, error.message);
|
||||
if (error.stack) {
|
||||
console.error('Stack:', error.stack);
|
||||
}
|
||||
} else {
|
||||
console.error(prefix, serializeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an object to console.log with proper serialization.
|
||||
* Handles circular references automatically.
|
||||
*
|
||||
*
|
||||
* @param prefix - Log prefix (e.g., '[ComponentName]')
|
||||
* @param obj - Object to log
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = { url: 'https://example.com', timeout: 5000 };
|
||||
@@ -120,5 +120,5 @@ export function logError(prefix: string, error: unknown): void {
|
||||
* ```
|
||||
*/
|
||||
export function logObject(prefix: string, obj: unknown): void {
|
||||
console.log(prefix, serializeObject(obj));
|
||||
console.log(prefix, serializeObject(obj));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Instagram URL Validation Utility
|
||||
*
|
||||
*
|
||||
* Validates that a URL is from Instagram's domain and uses HTTPS.
|
||||
* Accepts all Instagram URL formats (posts, reels, IGTV, etc.).
|
||||
*/
|
||||
@@ -12,23 +12,23 @@ export interface ValidationResult {
|
||||
|
||||
/**
|
||||
* Validate Instagram URL
|
||||
*
|
||||
*
|
||||
* Accepts:
|
||||
* - https://instagram.com/p/{post-id}
|
||||
* - https://www.instagram.com/p/{post-id}
|
||||
* - https://instagram.com/reel/{reel-id}
|
||||
* - https://instagram.com/tv/{tv-id}
|
||||
* - Any Instagram URL with query parameters
|
||||
*
|
||||
*
|
||||
* Rejects:
|
||||
* - Non-HTTPS URLs (http://)
|
||||
* - Non-Instagram domains
|
||||
* - Invalid URL format
|
||||
* - Subdomains other than www
|
||||
*
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @returns Validation result with valid flag and optional error message
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = validateInstagramUrl('https://instagram.com/reel/ABC123?utm_source=share');
|
||||
|
||||
Reference in New Issue
Block a user