This commit is contained in:
Giancarmine Salucci
2026-02-18 01:21:44 +01:00
parent 54321fd7c9
commit 49bccf8f15
84 changed files with 14474 additions and 13925 deletions

View File

@@ -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();

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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 }
);
}

View File

@@ -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';
}
}

View File

@@ -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

View File

@@ -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}`
};
}
}
}

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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.
*/

View File

@@ -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();

View File

@@ -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'
}

View File

@@ -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;
}
/**

View File

@@ -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()
};
}

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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');