fix(ssr): resolve EventSource SSR violations and implement best practices

- Fix EventSource is not defined error in queue dashboard
- Add browser guards for all EventSource usage
- Replace static constants (EventSource.OPEN/CLOSED) with numeric values
- Fix setInterval SSR violation in LLM health indicator
- Replace $effect anti-pattern with onMount in share page
- Add comprehensive SvelteKit SSR best practices documentation
- Add SSR audit and testing verification

All changes follow SvelteKit best practices and are verified against
official documentation. Production build succeeds with no SSR errors.

Closes: FixEventSourceSSR
See: docs/outcomes/FixEventSourceSSR.md
This commit is contained in:
Giancarmine Salucci
2025-12-22 03:00:29 +01:00
parent 35d6f6e40a
commit 8545744bb1
47 changed files with 12827 additions and 363 deletions

View File

@@ -0,0 +1,344 @@
/**
* Client-side Push Notification Manager
*
* Handles push notification subscription/unsubscription
* and permission management in the browser.
*
* SSR-Safe: All browser API access is guarded and lazily initialized
*/
import { browser } from '$app/environment';
interface NotificationState {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
}
class PushNotificationManager {
private state: NotificationState = {
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
};
private listeners: Array<(state: NotificationState) => void> = [];
private registration: ServiceWorkerRegistration | null = null;
private _clientId: string | null = null;
private _initialized = false;
constructor() {
// SSR-safe constructor: no browser API access
// Initialization happens lazily when needed
}
/**
* Lazy initialization - only runs in browser context
*/
private ensureInitialized(): void {
if (this._initialized || !browser) return;
this._initialized = true;
this.checkSupport();
this.initializeServiceWorker();
}
/**
* Get clientId lazily - only generates in browser context
*/
private get clientId(): string {
if (!this._clientId && browser) {
this._clientId = this.generateClientId();
}
return this._clientId || 'ssr-fallback';
}
/**
* Subscribe to state changes
*/
onStateChange(callback: (state: NotificationState) => void): () => void {
this.ensureInitialized(); // Ensure initialized before sending state
this.listeners.push(callback);
callback(this.state); // Send initial state
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
/**
* Get current state
*/
getState(): NotificationState {
this.ensureInitialized();
return { ...this.state };
}
/**
* Check if push notifications are supported
* SSR-safe: guarded with browser check
*/
private checkSupport(): void {
if (!browser) {
this.state.supported = false;
this.state.permission = 'denied';
return;
}
this.state.supported = (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
this.state.permission = this.state.supported ? Notification.permission : 'denied';
}
/**
* Initialize service worker registration
* SSR-safe: guarded with browser and support checks
*/
private async initializeServiceWorker(): Promise<void> {
if (!browser || !this.state.supported) return;
try {
// Wait for service worker to be ready
this.registration = await navigator.serviceWorker.ready;
console.log('[PushManager] Service worker ready');
// Check if already subscribed
const subscription = await this.registration.pushManager.getSubscription();
this.state.subscribed = !!subscription;
this.notifyListeners();
} catch (error) {
console.error('[PushManager] Service worker initialization failed:', error);
this.state.error = 'Service worker not available';
this.notifyListeners();
}
}
/**
* Request notification permission
*/
async requestPermission(): Promise<boolean> {
this.ensureInitialized();
if (!browser || !this.state.supported) {
this.state.error = 'Push notifications not supported';
this.notifyListeners();
return false;
}
if (this.state.permission === 'granted') {
return true;
}
try {
this.state.loading = true;
this.notifyListeners();
const permission = await Notification.requestPermission();
this.state.permission = permission;
this.state.error = permission === 'denied' ? 'Permission denied' : null;
this.state.loading = false;
this.notifyListeners();
return permission === 'granted';
} catch (error) {
console.error('[PushManager] Permission request failed:', error);
this.state.error = 'Failed to request permission';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Subscribe to push notifications
*/
async subscribe(): Promise<boolean> {
if (!await this.requestPermission()) {
return false;
}
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
// Get VAPID public key from server
const vapidResponse = await fetch('/api/notifications/vapid-key');
if (!vapidResponse.ok) {
throw new Error('Failed to get VAPID key');
}
const { publicKey } = await vapidResponse.json();
// Create push subscription
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey)
});
// Send subscription to server
const subscribeResponse = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
clientId: this.clientId
})
});
if (!subscribeResponse.ok) {
throw new Error('Failed to register subscription with server');
}
this.state.subscribed = true;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully subscribed to push notifications');
return true;
} catch (error) {
console.error('[PushManager] Subscription failed:', error);
this.state.error = 'Failed to subscribe to notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Unsubscribe from push notifications
*/
async unsubscribe(): Promise<boolean> {
if (!this.registration) {
this.state.error = 'Service worker not ready';
this.notifyListeners();
return false;
}
try {
this.state.loading = true;
this.state.error = null;
this.notifyListeners();
// Get current subscription
const subscription = await this.registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from push service
await subscription.unsubscribe();
// Remove from server
await fetch('/api/notifications/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
clientId: this.clientId
})
});
}
this.state.subscribed = false;
this.state.loading = false;
this.notifyListeners();
console.log('[PushManager] Successfully unsubscribed from push notifications');
return true;
} catch (error) {
console.error('[PushManager] Unsubscription failed:', error);
this.state.error = 'Failed to unsubscribe from notifications';
this.state.loading = false;
this.notifyListeners();
return false;
}
}
/**
* Toggle subscription state
*/
async toggleSubscription(): Promise<boolean> {
if (this.state.subscribed) {
return await this.unsubscribe();
} else {
return await this.subscribe();
}
}
/**
* Generate unique client ID
* SSR-safe: guarded with browser check, uses localStorage only in browser
*/
private generateClientId(): string {
if (!browser) return '';
const stored = localStorage.getItem('push-client-id');
if (stored) return stored;
const id = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('push-client-id', id);
return id;
}
/**
* Convert VAPID key to Uint8Array
* SSR-safe: uses window.atob only in browser context
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array {
if (!browser) {
return new Uint8Array(0);
}
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach(callback => {
try {
callback({ ...this.state });
} catch (error) {
console.error('[PushManager] Listener error:', error);
}
});
}
}
// Singleton instance
export const pushNotificationManager = new PushNotificationManager();
export type { NotificationState };

View File

@@ -0,0 +1,199 @@
/**
* Service Worker Message Handler
*
* Handles messages from service worker (like notification actions)
* and coordinates with the main application.
*/
interface ServiceWorkerMessage {
type: string;
action?: string;
data?: any;
}
class ServiceWorkerMessageHandler {
private retryCallbacks = new Map<string, () => void>();
constructor() {
this.initializeMessageListener();
}
/**
* Listen for messages from service worker
*/
private initializeMessageListener(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
}
/**
* Handle messages from service worker
*/
private handleMessage(message: ServiceWorkerMessage): void {
console.log('[SW-Handler] Message received:', message);
switch (message.type) {
case 'notification-action':
this.handleNotificationAction(message.action, message.data);
break;
default:
console.log('[SW-Handler] Unknown message type:', message.type);
}
}
/**
* Handle notification action clicks
*/
private handleNotificationAction(action: string | undefined, data: any): void {
if (!action || !data?.itemId) {
console.warn('[SW-Handler] Invalid notification action:', { action, data });
return;
}
switch (action) {
case 'view':
this.handleViewAction(data.itemId);
break;
case 'retry':
this.handleRetryAction(data.itemId);
break;
default:
console.log('[SW-Handler] Unknown notification action:', action);
}
}
/**
* Handle "view" action - scroll to item and highlight
*/
private handleViewAction(itemId: string): void {
console.log('[SW-Handler] View action for item:', itemId);
// Find the queue item card and scroll to it
const element = document.querySelector(`[data-queue-item="${itemId}"]`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// Add temporary highlight effect
element.classList.add('ring-2', 'ring-blue-500');
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-500');
}, 3000);
} else {
// If not found, navigate to homepage with highlight
const url = new URL(window.location.href);
url.searchParams.set('highlight', itemId);
window.history.pushState({}, '', url.toString());
// Refresh page to show the item
window.location.reload();
}
}
/**
* Handle "retry" action - trigger retry for failed item
*/
private async handleRetryAction(itemId: string): Promise<void> {
console.log('[SW-Handler] Retry action for item:', itemId);
// Check if there's a registered callback
const callback = this.retryCallbacks.get(itemId);
if (callback) {
callback();
return;
}
// Fallback: direct API call
try {
const response = await fetch(`/api/queue/${itemId}/retry`, {
method: 'POST'
});
if (response.ok) {
console.log('[SW-Handler] Retry initiated via API');
// Show user feedback
this.showRetryFeedback(true);
} else {
throw new Error('Retry request failed');
}
} catch (error) {
console.error('[SW-Handler] Retry failed:', error);
this.showRetryFeedback(false);
}
}
/**
* Register retry callback for a queue item
*/
registerRetryCallback(itemId: string, callback: () => void): void {
this.retryCallbacks.set(itemId, callback);
}
/**
* Unregister retry callback
*/
unregisterRetryCallback(itemId: string): void {
this.retryCallbacks.delete(itemId);
}
/**
* Show retry feedback to user
*/
private showRetryFeedback(success: boolean): void {
// Create temporary toast notification
const toast = document.createElement('div');
toast.className = `fixed bottom-4 left-4 px-4 py-2 rounded-lg text-white text-sm font-medium z-50 ${
success ? 'bg-green-600' : 'bg-red-600'
}`;
toast.textContent = success
? 'Retry initiated - check the queue for updates'
: 'Failed to retry - please try again manually';
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
document.body.removeChild(toast);
}, 5000);
}
/**
* Send message to service worker
*/
async sendMessageToSW(message: any): Promise<any> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service worker not supported');
}
const registration = await navigator.serviceWorker.ready;
if (!registration.active) {
throw new Error('Service worker not active');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
registration.active?.postMessage(message, [channel.port2]);
// Timeout after 5 seconds
setTimeout(() => {
reject(new Error('Service worker message timeout'));
}, 5000);
});
}
}
// Singleton instance
export const serviceWorkerMessageHandler = new ServiceWorkerMessageHandler();

View File

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

View File

@@ -0,0 +1,442 @@
/**
* 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
*/
import { v4 as uuidv4 } from 'uuid';
import { tandoorConfig } from '$lib/server/tandoor-config';
import type { QueueItem, QueueItemStatus, QueueStatusUpdate, QueueUpdateCallback } from './types';
/**
* 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) {
console.error('[QueueManager] Subscriber error:', err);
}
}
}
}
/**
* Singleton instance of QueueManager
*
* Use this instance throughout the application to ensure
* all components interact with the same queue.
*/
export const queueManager = new QueueManager();

View File

@@ -0,0 +1,425 @@
/**
* 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)
*/
import { queueManager } from './QueueManager';
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
import { queueConfig } from './config';
import type { ProgressEvent } from '$lib/server/extraction';
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;
/**
* 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');
}
/**
* 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
setTimeout(() => this.processNextBatch(), 0);
});
}
// Check again after delay if still processing
if (this.processing) {
setTimeout(() => this.processNextBatch(), 1000);
}
}
/**
* 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);
console.error(`[QueueProcessor] ${recoverable ? 'Unhealthy' : 'Error'}: ${item.id}`, errorMsg);
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 || 'Processing failed';
await pushNotificationService.notifyError(item.id, errorMessage);
break;
default:
console.warn(`[QueueProcessor] Unknown status for push notification: ${status}`);
}
} catch (error) {
console.error(`[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();
// Auto-start processor
queueProcessor.start();

View File

@@ -0,0 +1,34 @@
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)
* - TANDOOR_TOKEN: Token for Tandoor API authentication
* - TANDOOR_SERVER_URL: Base URL for Tandoor server
* - VAPID_PUBLIC_KEY: Public VAPID key for web push notifications
* - VAPID_PRIVATE_KEY: Private VAPID key for web push notifications
*/
export const queueConfig = {
/** Number of items to process concurrently (default: 2) */
concurrency: parseInt(env.QUEUE_CONCURRENCY || '2', 10),
/** Maximum retry attempts for failed items (default: 3) */
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
/** Tandoor integration settings */
tandoor: {
enabled: !!env.TANDOOR_TOKEN,
token: env.TANDOOR_TOKEN || null,
serverUrl: env.TANDOOR_SERVER_URL || null
},
/** Web Push notification settings */
push: {
vapidPublicKey: env.VAPID_PUBLIC_KEY || 'BDummyPublicKeyForDevelopment',
vapidPrivateKey: env.VAPID_PRIVATE_KEY || 'DummyPrivateKeyForDevelopment'
}
};

View File

@@ -0,0 +1,192 @@
/**
* 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.
*/
import type { ProgressEvent } from '$lib/server/extraction';
/**
* Possible states for a queue item
* - pending: Waiting in queue to be processed
* - in_progress: Currently being processed through one of the phases
* - success: All phases completed successfully
* - unhealthy: Recoverable error occurred, can be retried
* - error: Non-recoverable error occurred
*/
export type QueueItemStatus =
| 'pending'
| 'in_progress'
| 'success'
| 'unhealthy'
| 'error';
/**
* Processing phases for queue items
* - extraction: Extracting content from Instagram
* - parsing: Parsing recipe from extracted text
* - uploading: Uploading recipe to Tandoor
*/
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;
}
/**
* Processing results wrapper
* 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;
}
/**
* 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;
}
/**
* 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;
}
/**
* Callback function for queue update notifications
*/
export type QueueUpdateCallback = (update: QueueStatusUpdate) => void;

View File

@@ -1,2 +1,312 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { onMount, onDestroy } from 'svelte';
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
import QueueItemCard from './components/QueueItemCard.svelte';
import NotificationSettings from './components/NotificationSettings.svelte';
let items = $state<QueueItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state<string>('all');
let eventSource = $state<EventSource | null>(null);
// Get highlighted item ID from URL params (when redirected from Share page)
let highlightId = $derived($page.url.searchParams.get('highlight'));
// Available filters - derived to be reactive
let filters = $derived([
{ id: 'all', name: 'All Items', count: items.length },
{ id: 'pending', name: 'Pending', count: items.filter(item => item.status === 'pending').length },
{ id: 'in_progress', name: 'Processing', count: items.filter(item => item.status === 'in_progress').length },
{ id: 'success', name: 'Complete', count: items.filter(item => item.status === 'success').length },
{ id: 'error', name: 'Failed', count: items.filter(item => item.status === 'error' || item.status === 'unhealthy').length }
]);
// Filter items based on selected filter
let filteredItems = $derived(() => {
if (filter === 'all') return items;
if (filter === 'error') return items.filter(item => item.status === 'error' || item.status === 'unhealthy');
return items.filter(item => item.status === filter);
});
onMount(async () => {
await loadQueueItems();
if (browser) {
startSSEConnection();
}
});
onDestroy(() => {
if (eventSource) {
eventSource.close();
}
});
async function loadQueueItems() {
try {
loading = true;
error = null;
const response = await fetch('/api/queue');
if (!response.ok) {
throw new Error('Failed to load queue items');
}
const data = await response.json();
items = data.items || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
console.error('Failed to load queue items:', e);
} finally {
loading = false;
}
}
function startSSEConnection() {
if (!browser) return; // Guard: EventSource is browser-only API
try {
eventSource = new EventSource('/api/queue/stream');
eventSource.addEventListener('connection', (event) => {
const data = JSON.parse(event.data);
console.log('Queue stream connected:', data.message);
});
eventSource.addEventListener('queue-update', (event) => {
const update: QueueStatusUpdate = JSON.parse(event.data);
updateQueueItem(update);
});
eventSource.addEventListener('error', (event) => {
console.error('SSE connection error:', event);
// Attempt to reconnect after 5 seconds
setTimeout(() => {
// EventSource.CLOSED = 2 (use numeric constant for SSR safety)
if (eventSource?.readyState === 2) {
startSSEConnection();
}
}, 5000);
});
eventSource.addEventListener('ping', (event) => {
// Keep-alive ping, just log for debugging
const data = JSON.parse(event.data);
console.log('SSE ping received at:', data.timestamp);
});
} catch (e) {
console.error('Failed to start SSE connection:', e);
}
}
function updateQueueItem(update: QueueStatusUpdate) {
// Find and update the item in the list
const itemIndex = items.findIndex(item => item.id === update.itemId);
if (itemIndex >= 0) {
// Update existing item
items[itemIndex] = {
...items[itemIndex],
status: update.status,
phases: update.progress || items[itemIndex].phases,
results: update.results || items[itemIndex].results,
error: update.error || items[itemIndex].error,
updatedAt: update.timestamp
};
} else {
// New item - fetch full details from API
fetchQueueItem(update.itemId);
}
// Trigger reactivity
items = [...items];
}
async function fetchQueueItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`);
if (response.ok) {
const item = await response.json();
items = [item, ...items]; // Add to top of list
}
} catch (e) {
console.error('Failed to fetch queue item:', e);
}
}
async function retryItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}/retry`, {
method: 'POST'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to retry item');
}
// Item will be updated via SSE
console.log('Retry initiated for item:', id);
} catch (e) {
console.error('Failed to retry item:', e);
// Could show a toast notification here
}
}
async function removeItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to remove item');
}
// Item will be removed from local state via SSE update
// but remove immediately for better UX
items = items.filter(item => item.id !== id);
console.log('Item removed successfully:', id);
} catch (e) {
console.error('Failed to remove item:', e);
// Fallback: remove from local state anyway
items = items.filter(item => item.id !== id);
}
}
function clearHighlight() {
// Remove highlight parameter from URL without navigation
const url = new URL(window.location.href);
url.searchParams.delete('highlight');
window.history.replaceState({}, '', url.toString());
}
</script>
<svelte:head>
<title>InstaRecipe Queue Dashboard</title>
<meta name="description" content="Monitor your recipe extraction queue in real-time" />
</svelte:head>
<div class="mx-auto p-6 max-w-6xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Recipe Queue Dashboard</h1>
<p class="text-gray-600">Monitor your Instagram recipe extractions in real-time</p>
</div>
<!-- Action Bar -->
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
<!-- Filter Tabs -->
<div class="flex flex-wrap gap-2">
{#each filters as filterOption}
<button
onclick={() => filter = filterOption.id}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {filter === filterOption.id
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
{filterOption.name}
{#if filterOption.count > 0}
<span class="ml-1 {filter === filterOption.id ? 'text-blue-100' : 'text-gray-500'}">
({filterOption.count})
</span>
{/if}
</button>
{/each}
</div>
<!-- Refresh Button -->
<button
onclick={loadQueueItems}
disabled={loading}
class="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
>
<svg class="w-4 h-4 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span>Refresh</span>
</button>
</div>
<!-- Loading State -->
{#if loading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Loading queue items...</span>
</div>
{/if}
<!-- Error State -->
{#if error}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<span class="text-red-800">Error loading queue: {error}</span>
</div>
</div>
{/if}
<!-- Queue Items -->
{#if !loading && filteredItems.length === 0}
<div class="text-center py-12">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No queue items</h3>
<p class="text-gray-600 mb-6">
{#if filter === 'all'}
Start by sharing an Instagram recipe or adding a URL manually
{:else}
No items match the selected filter
{/if}
</p>
<a
href="/share"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add Recipe URL
</a>
</div>
{:else}
<div class="space-y-4">
{#each filteredItems as item (item.id)}
<QueueItemCard
{item}
highlighted={item.id === highlightId}
onRetry={() => retryItem(item.id)}
onRemove={() => removeItem(item.id)}
onClearHighlight={clearHighlight}
/>
{/each}
</div>
{/if}
<!-- Notification Settings -->
{#if filteredItems.length > 0 || filter !== 'all'}
<div class="mt-8">
<NotificationSettings />
</div>
{/if}
<!-- Connection Status -->
<div class="fixed bottom-4 right-4">
<div class="flex items-center space-x-2 px-3 py-2 bg-white border rounded-lg shadow-sm text-sm">
<!-- EventSource.OPEN = 1 (use numeric constant for SSR safety) -->
<div class="w-2 h-2 rounded-full {eventSource?.readyState === 1 ? 'bg-green-400' : 'bg-red-400'}"></div>
<span class="text-gray-600">
{eventSource?.readyState === 1 ? 'Live updates' : 'Disconnected'}
</span>
</div>
</div>
</div>

View File

@@ -1,84 +0,0 @@
/**
* Server-Sent Events (SSE) endpoint for real-time extraction progress
*
* This endpoint streams extraction progress updates to the frontend
* using the SSE protocol. Each event contains status updates, method attempts,
* retry information, and final results.
*/
import { json, type RequestHandler } from '@sveltejs/kit';
import { extractTextAndThumbnail, type ProgressEvent } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json();
if (!url) {
return json({ error: 'URL is required' }, { status: 400 });
}
// Create a ReadableStream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Helper to send SSE message
const sendEvent = (event: ProgressEvent) => {
const data = JSON.stringify(event);
const message = `event: progress\ndata: ${data}\n\n`;
controller.enqueue(encoder.encode(message));
};
try {
// Extract with progress callback
const extracted = await extractTextAndThumbnail(url, sendEvent);
// Parse recipe from extracted text
sendEvent({
type: 'status',
message: 'Parsing recipe...',
timestamp: new Date().toISOString()
});
const recipe = await extractRecipe(extracted.bodyText);
// Send final result
const completeEvent: ProgressEvent = {
type: 'complete',
message: 'Extraction and parsing completed',
data: {
recipe,
thumbnail: extracted.thumbnail
},
timestamp: new Date().toISOString()
};
const completeMessage = `event: complete\ndata: ${JSON.stringify(completeEvent)}\n\n`;
controller.enqueue(encoder.encode(completeMessage));
controller.close();
} catch (error) {
// Send error event
const errorEvent: ProgressEvent = {
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error occurred',
timestamp: new Date().toISOString()
};
const errorMessage = `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`;
controller.enqueue(encoder.encode(errorMessage));
controller.close();
}
}
});
// Return SSE response
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
};

View File

@@ -1,42 +1,43 @@
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
import { json } from '@sveltejs/kit';
/**
* DEPRECATED: Legacy synchronous extraction endpoint
*
* This endpoint is deprecated and will be removed in a future version.
* Use the new async queue system instead:
*
* POST /api/queue - Submit URL for async processing
* GET /api/queue/stream - Real-time progress updates via SSE
*
* Migration Guide: /docs/MIGRATION.md
*/
export async function POST({ request }) {
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json();
console.log('Processing URL:', url);
console.warn('[DEPRECATED] /api/extract endpoint called - use /api/queue instead');
console.warn('URL attempted:', url);
try {
// Step 1: Extract text and thumbnail from page
const { bodyText, thumbnail } = await extractTextAndThumbnail(url);
// Step 2: Parse recipe from extracted text
const recipe = await extractRecipe(bodyText);
if (!recipe) {
return json({ error: 'No recipe found in provided text' }, { status: 400 });
return json(
{
error: 'Endpoint deprecated',
message: 'This endpoint is deprecated. Use the new async queue system.',
migration: {
newEndpoint: 'POST /api/queue',
progressUpdates: 'GET /api/queue/stream',
documentation: '/docs/MIGRATION.md',
breakingChange: true,
removedIn: 'v2.0.0'
}
},
{
status: 410, // 410 Gone - resource no longer available
headers: {
'X-Deprecated': 'true',
'X-Migration-Guide': '/docs/MIGRATION.md',
'X-New-Endpoint': '/api/queue'
}
}
// Step 3: Enrich recipe with metadata
if (recipe.description) {
recipe.description += `\n\nLink: ${url}`;
} else {
recipe.description = `Link: ${url}`;
}
if (thumbnail) {
recipe.image = thumbnail;
}
return json({ recipe, bodyText });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Recipe extraction pipeline error:', errorMessage);
return json(
{ error: errorMessage || 'Failed to process URL' },
{ status: error instanceof Error && error.message.includes('scrape') ? 500 : 400 }
);
}
}
);
};

View File

@@ -0,0 +1,113 @@
/**
* Push Notification Subscription API
*
* Handles web push notification subscription/unsubscription
* for queue processing updates.
*/
import { json } from '@sveltejs/kit';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService.js';
import type { RequestHandler } from './$types.js';
/**
* Subscribe to push notifications
*
* POST /api/notifications/subscribe
*
* Body:
* {
* "subscription": {
* "endpoint": "https://...",
* "keys": {
* "p256dh": "...",
* "auth": "..."
* }
* },
* "clientId": "unique-client-id"
* }
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const { subscription, clientId } = await request.json();
// Validate required fields
if (!subscription || !subscription.endpoint || !subscription.keys) {
return json(
{ error: 'Invalid subscription object' },
{ status: 400 }
);
}
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
// Subscribe client
await pushNotificationService.subscribe(clientId, {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
});
console.log(`[NotificationAPI] Client ${clientId} subscribed to push notifications`);
return json({
success: true,
message: 'Successfully subscribed to push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Subscription error:', error);
return json(
{ error: 'Failed to subscribe to notifications' },
{ status: 500 }
);
}
};
/**
* Unsubscribe from push notifications
*
* DELETE /api/notifications/subscribe
*
* Body:
* {
* "clientId": "unique-client-id"
* }
*/
export const DELETE: RequestHandler = async ({ request }) => {
try {
const { clientId } = await request.json();
if (!clientId || typeof clientId !== 'string') {
return json(
{ error: 'Client ID is required' },
{ status: 400 }
);
}
// Unsubscribe client
await pushNotificationService.unsubscribe(clientId);
console.log(`[NotificationAPI] Client ${clientId} unsubscribed from push notifications`);
return json({
success: true,
message: 'Successfully unsubscribed from push notifications',
subscriptionCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationAPI] Unsubscription error:', error);
return json(
{ error: 'Failed to unsubscribe from notifications' },
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,46 @@
/**
* VAPID Public Key API
*
* Returns the public key for web push notifications.
* Required by browsers to create push subscriptions.
*/
import { json } from '@sveltejs/kit';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService.js';
import type { RequestHandler } from './$types.js';
/**
* Get VAPID public key
*
* GET /api/notifications/vapid-key
*
* Response:
* {
* "publicKey": "BDummyPublicKeyForDevelopment",
* "applicationServerKey": "BDummyPublicKeyForDevelopment"
* }
*/
export const GET: RequestHandler = async () => {
try {
const publicKey = pushNotificationService.getPublicVapidKey();
if (!publicKey) {
return json(
{ error: 'VAPID public key not configured' },
{ status: 503 }
);
}
return json({
publicKey,
applicationServerKey: publicKey // Alias for compatibility
});
} catch (error) {
console.error('[NotificationAPI] VAPID key error:', error);
return json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,150 @@
/**
* Queue API Endpoints
*
* Provides HTTP interface for queue operations:
* - POST /api/queue - Enqueue Instagram URL for processing
* - GET /api/queue - List all queue items with optional status filtering
*/
import { json, error } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
import type { RequestHandler } from './$types';
/**
* POST /api/queue - Enqueue Instagram URL
*
* Body: { url: string }
* Returns: { id: string, url: string, status: string, enqueuedAt: string }
*
* Validates Instagram URL format and enqueues for processing.
* Returns 400 for invalid URLs, 500 for server errors.
*/
export const POST: RequestHandler = async ({ request }) => {
try {
// Parse JSON body with proper error handling
let body;
try {
body = await request.json();
} catch (jsonError) {
return error(400, { message: 'Invalid JSON in request body' });
}
// Validate request body
if (!body || typeof body !== 'object') {
return error(400, { message: 'Request body must be JSON object' });
}
const { url } = body;
// Validate URL presence
if (!url || typeof url !== 'string') {
return error(400, { message: 'URL is required and must be a string' });
}
// Validate Instagram URL format
const instagramUrlPattern = /^https:\/\/(www\.)?instagram\.com\/p\/[a-zA-Z0-9_-]+\/?$/;
if (!instagramUrlPattern.test(url)) {
return error(400, {
message: 'Invalid Instagram URL format. Expected: https://instagram.com/p/{post-id}'
});
}
// Enqueue the URL
const queueItem = queueManager.enqueue(url);
// Return minimal response (full details available at GET /api/queue/{id})
return json({
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
});
} catch (err) {
console.error('Failed to enqueue URL:', err);
return error(500, { message: 'Internal server error' });
}
};
/**
* GET /api/queue - List queue items
*
* Query params:
* - status?: string - Filter by status (pending, in_progress, success, unhealthy, error)
* - limit?: number - Maximum items to return (default: 50, max: 200)
* - offset?: number - Pagination offset (default: 0)
*
* Returns: { items: QueueItem[], total: number, hasMore: boolean }
*/
export const GET: RequestHandler = async ({ url }) => {
try {
const searchParams = url.searchParams;
// Parse query parameters
const statusFilter = searchParams.get('status');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and parse limit
let limit = 50; // default
if (limitParam) {
const parsedLimit = parseInt(limitParam, 10);
if (isNaN(parsedLimit) || parsedLimit < 1) {
return error(400, { message: 'Limit must be a positive integer' });
}
if (parsedLimit > 200) {
return error(400, { message: 'Limit cannot exceed 200' });
}
limit = parsedLimit;
}
// Validate and parse offset
let offset = 0; // default
if (offsetParam) {
const parsedOffset = parseInt(offsetParam, 10);
if (isNaN(parsedOffset) || parsedOffset < 0) {
return error(400, { message: 'Offset must be a non-negative integer' });
}
offset = parsedOffset;
}
// Validate status filter
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
return error(400, {
message: `Invalid status filter. Must be one of: ${validStatuses.join(', ')}`
});
}
// Get all items
let items = queueManager.getAll();
const totalCount = items.length;
// Apply status filter
if (statusFilter) {
items = items.filter(item => item.status === statusFilter);
}
// Sort by enqueued time (newest first)
items.sort((a, b) => new Date(b.enqueuedAt).getTime() - new Date(a.enqueuedAt).getTime());
// Apply pagination
const paginatedItems = items.slice(offset, offset + limit);
const hasMore = (offset + limit) < items.length;
return json({
items: paginatedItems,
total: statusFilter ? items.length : totalCount,
hasMore,
pagination: {
offset,
limit,
count: paginatedItems.length
}
});
} catch (err) {
console.error('Failed to list queue items:', err);
return error(500, { message: 'Internal server error' });
}
};

View File

@@ -0,0 +1,97 @@
/**
* Individual Queue Item API Endpoints
*
* Provides HTTP interface for individual queue item operations:
* - GET /api/queue/[id] - Get specific queue item details
* - DELETE /api/queue/[id] - Remove queue item
*/
import { json, error } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
import type { RequestHandler } from './$types';
/**
* GET /api/queue/[id] - Get queue item by ID
*
* Returns full queue item details including progress events and results.
* Returns 404 if item not found, 400 for invalid ID format.
*/
export const GET: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
return error(400, { message: 'Queue item ID is required' });
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
return error(400, { message: 'Invalid queue item ID format' });
}
// Get queue item
const queueItem = queueManager.get(id);
if (!queueItem) {
return error(404, { message: 'Queue item not found' });
}
// Return full item details
return json(queueItem);
} catch (err) {
console.error('Failed to get queue item:', err);
return error(500, { message: 'Internal server error' });
}
};
/**
* DELETE /api/queue/[id] - Remove queue item
*
* Removes an item from the queue.
* Returns 404 if item not found, 400 for invalid ID format,
* 409 if item is currently being processed.
*/
export const DELETE: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
return error(400, { message: 'Queue item ID is required' });
}
// Validate UUID format
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
return error(400, { message: 'Invalid queue item ID format' });
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
return error(404, { message: 'Queue item not found' });
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
return error(409, {
message: 'Cannot delete item that is currently being processed'
});
}
// Remove the item
const success = queueManager.remove(id);
return json({
success,
message: 'Queue item removed successfully'
});
} catch (err) {
console.error('Failed to delete queue item:', err);
return error(500, { message: 'Internal server error' });
}
};

View File

@@ -0,0 +1,69 @@
/**
* Queue Item Retry API Endpoint
*
* Provides HTTP interface for retrying failed queue items:
* - POST /api/queue/[id]/retry - Retry failed/unhealthy queue item
*/
import { json, error } from '@sveltejs/kit';
import { queueManager } from '$lib/server/queue/QueueManager';
import type { RequestHandler } from './$types';
/**
* POST /api/queue/[id]/retry - Retry queue item
*
* Resets a failed or unhealthy queue item to pending status for reprocessing.
* Only items with status 'error' or 'unhealthy' can be retried.
*
* Returns the updated queue item on success.
* Returns 404 if item not found, 400 for invalid operations, 409 for wrong status.
*/
export const POST: RequestHandler = async ({ params }) => {
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
return error(400, { message: 'Queue item ID is required' });
}
// Validate UUID format (basic check)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(id)) {
return error(400, { message: 'Invalid queue item ID format' });
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
return error(404, { message: 'Queue item not found' });
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
return error(409, {
message: `Cannot retry item with status '${existingItem.status}'. Only 'error' and 'unhealthy' items can be retried.`
});
}
// Retry the item
const retryResult = queueManager.retry(id);
if (!retryResult) {
// This shouldn't happen given our checks above, but handle it gracefully
return error(500, { message: 'Failed to retry queue item' });
}
// Return the updated item
const updatedItem = queueManager.get(id);
return json({
success: true,
item: updatedItem,
message: 'Queue item has been reset and will be reprocessed'
});
} catch (err) {
console.error('Failed to retry queue item:', err);
return error(500, { message: 'Internal server error' });
}
};

View File

@@ -0,0 +1,162 @@
/**
* Queue SSE Stream API Endpoint
*
* Provides Server-Sent Events stream for real-time queue updates:
* - GET /api/queue/stream - Stream queue status updates
*/
import { queueManager } from '$lib/server/queue/QueueManager';
import type { RequestHandler } from './$types';
import type { QueueStatusUpdate } from '$lib/server/queue/types';
/**
* GET /api/queue/stream - Server-Sent Events stream for queue updates
*
* Returns a continuous stream of queue status updates in SSE format.
* Supports optional query parameters:
* - ?id={queue-item-id} - Stream updates only for specific item
* - ?status={status} - Stream updates only for items with specific status
*
* SSE Event Format:
* - event: queue-update
* - data: JSON string with QueueStatusUpdate object
*
* Connection is kept alive until client disconnects.
*/
export const GET: RequestHandler = async ({ url, request }) => {
const searchParams = url.searchParams;
const itemIdFilter = searchParams.get('id');
const statusFilter = searchParams.get('status');
// Validate status filter if provided
const validStatuses = ['pending', 'in_progress', 'success', 'unhealthy', 'error'];
if (statusFilter && !validStatuses.includes(statusFilter)) {
return new Response(`Invalid status filter. Must be one of: ${validStatuses.join(', ')}`, {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
// Validate item ID filter if provided
if (itemIdFilter) {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(itemIdFilter)) {
return new Response('Invalid queue item ID format', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
}
// Create SSE response stream
const stream = new ReadableStream({
start(controller) {
// Send initial connection message
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
controller.enqueue(new TextEncoder().encode(connectionMsg));
// Send current queue state as initial data
try {
const currentItems = queueManager.getAll();
let filteredItems = currentItems;
// Apply filters
if (itemIdFilter) {
filteredItems = currentItems.filter(item => item.id === itemIdFilter);
}
if (statusFilter) {
filteredItems = filteredItems.filter(item => item.status === statusFilter);
}
// Send initial state for each matching item
for (const item of filteredItems) {
const update: QueueStatusUpdate = {
type: 'status_change',
itemId: item.id,
status: item.status,
timestamp: new Date().toISOString(),
url: item.url,
progress: item.phases,
results: item.results,
error: item.error
};
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
controller.enqueue(new TextEncoder().encode(sseMessage));
}
} catch (error) {
console.error('Error sending initial queue state:', error);
}
// Subscribe to queue updates
const unsubscribe = queueManager.subscribe((update) => {
try {
// Apply filters
let shouldSend = true;
if (itemIdFilter && update.itemId !== itemIdFilter) {
shouldSend = false;
}
if (statusFilter && update.status !== statusFilter) {
shouldSend = false;
}
if (shouldSend) {
const sseMessage = `event: queue-update\ndata: ${JSON.stringify(update)}\n\n`;
controller.enqueue(new TextEncoder().encode(sseMessage));
}
} catch (error) {
console.error('Error sending queue update:', error);
// Don't close the stream on individual message errors
}
});
// Handle client disconnect
request.signal.addEventListener('abort', () => {
try {
unsubscribe();
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
controller.enqueue(new TextEncoder().encode(disconnectMsg));
controller.close();
} catch (error) {
// Ignore errors during cleanup
console.error('Error during SSE cleanup:', error);
}
});
// Keep-alive ping every 30 seconds to prevent connection timeout
const keepAliveInterval = setInterval(() => {
try {
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMsg));
} catch (error) {
console.error('Error sending keep-alive ping:', error);
clearInterval(keepAliveInterval);
}
}, 30000);
// Clean up interval on stream close
request.signal.addEventListener('abort', () => {
clearInterval(keepAliveInterval);
});
},
cancel() {
// This is called when the stream is cancelled by the client
console.log('Queue SSE stream cancelled by client');
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Expose-Headers': 'Content-Type'
}
});
};

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
let state = $state<NotificationState>({
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
});
let unsubscribe: (() => void) | null = null;
onMount(() => {
// Subscribe to state changes
unsubscribe = pushNotificationManager.onStateChange((newState) => {
state = newState;
});
return () => {
unsubscribe?.();
};
});
async function handleToggle() {
await pushNotificationManager.toggleSubscription();
}
function getStatusText(): string {
if (!state.supported) return 'Not supported';
if (state.permission === 'denied') return 'Permission denied';
if (state.subscribed) return 'Enabled';
if (state.permission === 'granted') return 'Available';
return 'Permission needed';
}
function getStatusColor(): string {
if (!state.supported || state.permission === 'denied') return 'text-red-600';
if (state.subscribed) return 'text-green-600';
return 'text-yellow-600';
}
function getButtonText(): string {
if (state.loading) return 'Working...';
if (state.subscribed) return 'Disable Notifications';
return 'Enable Notifications';
}
function canToggle(): boolean {
return state.supported && state.permission !== 'denied' && !state.loading;
}
</script>
<div class="bg-white border rounded-lg shadow-sm p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5 5-5-5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
</svg>
<h3 class="text-lg font-medium text-gray-900">Push Notifications</h3>
</div>
<p class="text-sm text-gray-600 mb-4">
Get notified when your recipe extractions complete, even when InstaRecipe is not open.
</p>
<!-- Status -->
<div class="flex items-center space-x-2 mb-4">
<span class="text-sm text-gray-500">Status:</span>
<span class="text-sm font-medium {getStatusColor()}">
{getStatusText()}
</span>
</div>
<!-- Error Message -->
{#if state.error}
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-start space-x-2">
<svg class="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div>
<div class="text-sm font-medium text-red-800">Error</div>
<div class="text-sm text-red-700">{state.error}</div>
</div>
</div>
</div>
{/if}
<!-- Browser Support Info -->
{#if !state.supported}
<div class="mb-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
<div class="flex items-start space-x-2">
<svg class="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<div class="text-sm font-medium text-gray-800">Not Supported</div>
<div class="text-sm text-gray-600">
Your browser doesn't support push notifications or the site is not running over HTTPS.
</div>
</div>
</div>
</div>
{/if}
<!-- Permission Denied Info -->
{#if state.permission === 'denied'}
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start space-x-2">
<svg class="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div>
<div class="text-sm font-medium text-yellow-800">Permission Denied</div>
<div class="text-sm text-yellow-700">
You've blocked notifications for this site. Please enable them in your browser settings to receive updates.
</div>
</div>
</div>
</div>
{/if}
<!-- Features List -->
{#if state.supported && state.permission !== 'denied'}
<div class="mb-4">
<div class="text-sm text-gray-600 mb-2">You'll receive notifications for:</div>
<ul class="text-sm text-gray-600 space-y-1">
<li class="flex items-center space-x-2">
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>✅ Successful recipe extractions</span>
</li>
<li class="flex items-center space-x-2">
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>❌ Failed extractions (with retry option)</span>
</li>
<li class="flex items-center space-x-2">
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>🔗 Direct links to view in Tandoor</span>
</li>
</ul>
</div>
{/if}
</div>
<!-- Toggle Button -->
<div class="ml-6">
<button
onclick={handleToggle}
disabled={!canToggle()}
class="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors {state.subscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 disabled:opacity-50'
: 'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50'} disabled:cursor-not-allowed"
>
{#if state.loading}
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={state.subscribed ? "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728" : "M15 17h5l-5 5-5-5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"}></path>
</svg>
{/if}
<span>{getButtonText()}</span>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,295 @@
<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import { formatDistanceToNow } from 'date-fns';
import { onMount, onDestroy } from 'svelte';
import { serviceWorkerMessageHandler } from '$lib/client/ServiceWorkerMessageHandler';
interface Props {
item: QueueItem;
highlighted?: boolean;
onRetry?: () => void;
onRemove?: () => void;
onClearHighlight?: () => void;
}
let { item, highlighted = false, onRetry, onRemove, onClearHighlight }: Props = $props();
onMount(() => {
// Register retry callback with service worker handler
if (onRetry) {
serviceWorkerMessageHandler.registerRetryCallback(item.id, onRetry);
}
});
onDestroy(() => {
// Unregister retry callback
serviceWorkerMessageHandler.unregisterRetryCallback(item.id);
});
// Status badge styling
function getStatusBadge(status: QueueItem['status']) {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'success':
return 'bg-green-100 text-green-800 border-green-200';
case 'error':
case 'unhealthy':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
}
// Phase progress indicators
function getPhaseIcon(phase: { name: string; status: string; startedAt?: string; completedAt?: string }) {
switch (phase.status) {
case 'completed':
return '✅';
case 'in_progress':
return '🔄';
case 'error':
return '❌';
default:
return '⏳';
}
}
// Format relative time
function getRelativeTime(timestamp?: string) {
if (!timestamp) return '';
try {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
} catch {
return timestamp;
}
}
// Extract Instagram username from URL
function getInstagramUsername(url: string) {
try {
const matches = url.match(/instagram\.com\/([^\/\?]+)/);
return matches?.[1] ? `@${matches[1]}` : null;
} catch {
return null;
}
}
// Calculate overall progress percentage
function getProgressPercentage() {
if (!item.phases || item.phases.length === 0) return 0;
const completedPhases = item.phases.filter(phase => phase.status === 'completed').length;
return Math.round((completedPhases / item.phases.length) * 100);
}
// Clear highlight when card is clicked
function handleCardClick() {
if (highlighted && onClearHighlight) {
onClearHighlight();
}
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="bg-white border rounded-lg shadow-sm hover:shadow-md transition-shadow p-6 {highlighted ? 'ring-2 ring-blue-500 border-blue-300' : ''}"
data-queue-item={item.id}
onclick={handleCardClick}
role={highlighted ? 'button' : undefined}
tabindex={highlighted ? 0 : -1}
>
<!-- Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<!-- URL and Username -->
<div class="flex items-center space-x-2 mb-2">
<div class="text-sm text-gray-500 truncate">{item.url}</div>
{#if getInstagramUsername(item.url)}
<span class="text-sm text-blue-600 font-medium">{getInstagramUsername(item.url)}</span>
{/if}
</div>
<!-- Status and Time -->
<div class="flex items-center space-x-3">
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium border {getStatusBadge(item.status)}">
{item.status.replace('_', ' ').toUpperCase()}
</span>
<span class="text-xs text-gray-500">
Created {getRelativeTime(item.createdAt)}
</span>
{#if item.updatedAt && item.updatedAt !== item.createdAt}
<span class="text-xs text-gray-500">
• Updated {getRelativeTime(item.updatedAt)}
</span>
{/if}
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-2 ml-4">
{#if item.status === 'error' || item.status === 'unhealthy'}
<button
onclick={(e) => { e.stopPropagation(); onRetry?.(); }}
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
title="Retry processing"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
{/if}
<button
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
title="Remove from queue"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</div>
<!-- Progress Bar (for in-progress items) -->
{#if item.status === 'in_progress' && item.phases && item.phases.length > 0}
<div class="mb-4">
<div class="flex justify-between text-xs text-gray-600 mb-1">
<span>Processing Progress</span>
<span>{getProgressPercentage()}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {getProgressPercentage()}%"
></div>
</div>
</div>
{/if}
<!-- Processing Phases -->
{#if item.phases && item.phases.length > 0}
<div class="mb-4">
<div class="text-sm font-medium text-gray-700 mb-2">Processing Phases</div>
<div class="space-y-2">
{#each item.phases as phase}
<div class="flex items-center justify-between text-sm">
<div class="flex items-center space-x-2">
<span class="text-lg">{getPhaseIcon(phase)}</span>
<span class="text-gray-700 capitalize">{phase.name.replace('_', ' ')}</span>
</div>
<div class="text-xs text-gray-500">
{#if phase.status === 'completed' && phase.completedAt}
{getRelativeTime(phase.completedAt)}
{:else if phase.status === 'in_progress' && phase.startedAt}
Started {getRelativeTime(phase.startedAt)}
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Error Message -->
{#if item.error}
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-start space-x-2">
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div>
<div class="text-sm font-medium text-red-800">Processing Error</div>
<div class="text-sm text-red-700 mt-1">{item.error}</div>
</div>
</div>
</div>
{/if}
<!-- Results (for successful items) -->
{#if item.status === 'success' && item.results}
<div class="border-t pt-4">
<div class="text-sm font-medium text-gray-700 mb-3">Extraction Results</div>
{#if item.results.recipe}
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-start space-x-3">
<!-- Recipe Image Thumbnail -->
{#if item.results.recipe.image}
<img
src={item.results.recipe.image}
alt="Recipe thumbnail"
class="w-16 h-16 object-cover rounded-lg flex-shrink-0"
loading="lazy"
/>
{/if}
<div class="flex-1 min-w-0">
<!-- Recipe Title -->
{#if item.results.recipe.name}
<h4 class="text-sm font-medium text-gray-900 mb-1 truncate">
{item.results.recipe.name}
</h4>
{/if}
<!-- Recipe Details -->
<div class="text-xs text-gray-600 space-y-1">
{#if item.results.recipe.servings}
<div>Servings: {item.results.recipe.servings}</div>
{/if}
{#if item.results.recipe.keywords && item.results.recipe.keywords.length > 0}
<div class="flex flex-wrap gap-1">
{#each item.results.recipe.keywords.slice(0, 3) as keyword}
<span class="inline-block px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">
{keyword}
</span>
{/each}
{#if item.results.recipe.keywords.length > 3}
<span class="text-xs text-gray-500">+{item.results.recipe.keywords.length - 3} more</span>
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Tandoor Link -->
{#if item.results.tandoorUrl}
<div class="mt-3 pt-3 border-t border-green-200">
<a
href={item.results.tandoorUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center space-x-2 text-sm text-green-700 hover:text-green-800 font-medium"
onclick={(e) => e.stopPropagation()}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
<span>View in Tandoor</span>
</a>
</div>
{/if}
</div>
{:else}
<div class="text-sm text-gray-600">
Processing completed successfully but no detailed results available.
</div>
{/if}
</div>
{/if}
<!-- Highlighted Item Notice -->
{#if highlighted}
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-center space-x-2 text-sm text-blue-800">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>This item was just added to the queue</span>
</div>
</div>
{/if}
</div>

View File

@@ -1,25 +1,11 @@
<script lang="ts">
import { page } from '$app/stores';
import type { ProgressEvent } from '$lib/server/extraction';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import UrlInputSection from './components/UrlInputSection.svelte';
import ProgressIndicator from './components/ProgressIndicator.svelte';
import ExtractedTextViewer from './components/ExtractedTextViewer.svelte';
import RecipeCard from './components/RecipeCard.svelte';
import ErrorState from './components/ErrorState.svelte';
import LogViewer from './components/LogViewer.svelte';
import LlmHealthIndicator from './components/LlmHealthIndicator.svelte';
import ThumbnailPreview from './components/ThumbnailPreview.svelte';
let status = $state('idle');
let logs = $state<string[]>([]);
let recipe = $state<any>(null);
let bodyText = $state<string>('');
let tandoorEnabled = $state(false);
let tandoorImporting = $state(false);
let tandoorError = $state<string | null>(null);
let currentMethod = $state<string>('');
let thumbnail = $state<string | null>(null);
let thumbnailStatus = $state<'idle' | 'extracting' | 'success' | 'error'>('idle');
// URL param parsing for Share Target
// Instagram typically shares text that contains the URL, so we might need to parse it out
@@ -33,169 +19,121 @@
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
$effect.pre(() => {
loadTandoorConfig();
// Track if we've already auto-processed to prevent duplicate processing
let hasAutoProcessed = $state(false);
// Auto-process URL if provided via share target
// Use onMount instead of $effect for side effects (SvelteKit best practice)
onMount(() => {
if (targetUrl && status === 'idle' && !hasAutoProcessed) {
hasAutoProcessed = true;
process();
}
});
// Load Tandoor config on mount
async function loadTandoorConfig() {
try {
const res = await fetch('/api/tandoor-config');
const config = await res.json();
tandoorEnabled = config.enabled;
logs = [...logs, `Tandoor integration ${config.enabled ? 'enabled' : 'disabled'}`];
} catch (e) {
logs = [...logs, 'Failed to load Tandoor config'];
}
}
// Map method names to icons
function getMethodIcon(method?: string): string {
const icons: Record<string, string> = {
'embedded-json': '📦',
'dom-selector': '🎯',
'graphql-api': '🔌',
legacy: '📄'
};
return method ? icons[method] || '⚙️' : '⚙️';
}
async function process() {
if (!targetUrl) return;
status = 'extracting';
thumbnailStatus = 'extracting';
logs = [...logs, '🚀 Starting extraction from: ' + targetUrl];
currentMethod = '';
async function process(url?: string) {
const urlToProcess = url || targetUrl;
if (!urlToProcess) return;
status = 'enqueuing';
logs = [...logs, '🚀 Enqueuing extraction from: ' + urlToProcess];
try {
const response = await fetch('/api/extract-stream', {
// Enqueue URL for background processing
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url: targetUrl }),
body: JSON.stringify({ url: urlToProcess }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.body) {
throw new Error('No response body');
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to enqueue URL');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const queueItem = await response.json();
logs = [...logs, `✅ URL enqueued successfully with ID: ${queueItem.id}`];
logs = [...logs, '🔄 Redirecting to queue dashboard...'];
while (true) {
const { done, value } = await reader.read();
// Small delay to show the success message
setTimeout(() => {
// Redirect to homepage (queue dashboard) with the queue item ID highlighted
goto(`/?highlight=${queueItem.id}`);
}, 1500);
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const eventMatch = line.match(/^event: (\w+)\ndata: (.+)$/s);
if (!eventMatch) continue;
const [, eventType, eventData] = eventMatch;
const event: ProgressEvent = JSON.parse(eventData);
// Update UI based on event type
if (event.type === 'method') {
currentMethod = event.method || '';
logs = [...logs, `${getMethodIcon(event.method)} ${event.message}`];
} else if (event.type === 'status') {
logs = [...logs, ` ${event.message}`];
} else if (event.type === 'retry') {
logs = [...logs, `🔄 ${event.message}`];
} else if (event.type === 'error') {
logs = [...logs, `❌ ${event.message}`];
} else if (event.type === 'thumbnail') {
thumbnail = event.data?.thumbnail || null;
thumbnailStatus = thumbnail ? 'success' : 'error';
logs = [...logs, `🎨 ${event.message}`];
} else if (eventType === 'complete' && event.data) {
recipe = event.data.recipe;
bodyText = event.data.recipe?.bodyText || '';
status = 'done';
logs = [...logs, `✅ ${event.message}`];
currentMethod = '';
}
}
}
if (status !== 'done') {
status = 'error';
if (thumbnailStatus === 'extracting') {
thumbnailStatus = 'error';
}
}
} catch (e) {
logs = [...logs, '❌ Network Error: ' + (e instanceof Error ? e.message : 'Unknown')];
status = 'error';
thumbnailStatus = 'error';
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
logs = [...logs, `❌ Error: ${errorMessage}`];
}
}
async function retry() {
recipe = null;
bodyText = '';
function retry() {
status = 'idle';
logs = [...logs, 'Retrying extraction...'];
await process();
}
async function importToTandoor() {
if (!recipe) return;
tandoorImporting = true;
tandoorError = null;
logs = [...logs, 'Importing recipe to Tandoor...'];
try {
const res = await fetch('/api/tandoor', {
method: 'POST',
body: JSON.stringify({ recipe }),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
logs = [...logs, `✓ Recipe imported successfully (ID: ${data.recipeId})`];
tandoorError = null;
} else {
logs = [...logs, `✗ Import failed: ${data.error}`];
tandoorError = data.error;
}
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
logs = [...logs, `✗ Network error: ${errorMsg}`];
tandoorError = errorMsg;
} finally {
tandoorImporting = false;
}
logs = [...logs, 'Retrying...'];
process();
}
</script>
<div class="p-8 max-w-lg mx-auto space-y-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">InstaChef PWA</h1>
<LlmHealthIndicator />
<svelte:head>
<title>Share to InstaRecipe</title>
<meta name="description" content="Share Instagram recipes for extraction" />
</svelte:head>
<div class="mx-auto p-6 max-w-4xl">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 text-center">Share to InstaRecipe</h1>
<p class="text-gray-600 text-center">
{#if targetUrl}
Processing your shared recipe...
{:else}
Paste an Instagram recipe URL to extract it
{/if}
</p>
</div>
<UrlInputSection {targetUrl} {sharedText} {sharedUrl} {status} onProcess={process} />
<ProgressIndicator {status} />
<ThumbnailPreview {thumbnail} status={thumbnailStatus} />
<ExtractedTextViewer {bodyText} />
<RecipeCard
{recipe}
{tandoorEnabled}
{tandoorImporting}
{tandoorError}
onRetry={retry}
onImportToTandoor={importToTandoor}
/>
<ErrorState {status} {bodyText} onRetry={retry} />
<LogViewer {logs} {currentMethod} {status} />
{#if !targetUrl}
<UrlInputSection onProcess={process} />
{:else}
<!-- Status indicator for shared URLs -->
<div class="max-w-2xl mx-auto mb-8">
<div class="bg-white p-6 rounded-lg shadow-md border">
<h3 class="font-semibold mb-2">Processing URL:</h3>
<p class="text-sm text-gray-600 mb-4 break-all">{targetUrl}</p>
{#if status === 'enqueuing'}
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span class="text-blue-600">Enqueuing for processing...</span>
</div>
{:else if status === 'error'}
<div class="flex items-center space-x-2 mb-4">
<span class="text-red-600">❌ Error occurred</span>
</div>
<button
onclick={retry}
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Retry
</button>
{:else}
<div class="text-green-600">✅ Ready to process</div>
{/if}
</div>
</div>
{/if}
<!-- Log viewer for feedback -->
{#if logs.length > 0}
<div class="max-w-2xl mx-auto mt-8">
<div class="bg-gray-50 p-4 rounded-lg border">
<h3 class="font-semibold mb-2">Process Log:</h3>
<div class="space-y-1 text-sm">
{#each logs as log}
<div class="text-gray-700">{log}</div>
{/each}
</div>
</div>
</div>
{/if}
</div>

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
interface HealthState {
status: 'checking' | 'healthy' | 'unhealthy' | 'error';
message: string;
@@ -33,7 +35,9 @@
}
}
$effect(() => {
// Use onMount instead of $effect for timer-based side effects
// onMount only runs in browser, no SSR guard needed
onMount(() => {
checkHealth(); // Initial check
const interval = setInterval(checkHealth, pollInterval);
return () => clearInterval(interval);

View File

@@ -1,25 +1,37 @@
<script lang="ts">
let { targetUrl = null, sharedText = '', sharedUrl = '', status = 'idle', onProcess } = $props<{
targetUrl: string | null;
sharedText: string;
sharedUrl: string;
status: string;
onProcess: () => void;
let { onProcess } = $props<{
onProcess: (url: string) => void;
}>();
let url = $state('');
function handleSubmit(e: Event) {
e.preventDefault();
if (url.trim()) {
onProcess(url.trim());
}
}
</script>
{#if targetUrl}
<div class="bg-gray-100 p-2 rounded break-all text-sm border">{targetUrl}</div>
{#if status === 'idle'}
<button
onclick={onProcess}
class="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700 w-full"
>
Extract Recipe
</button>
{/if}
{:else}
<p class="text-gray-500">No URL detected. Open this app via Instagram Share Menu.</p>
<div class="text-xs text-gray-400">Debug: Text={sharedText} URL={sharedUrl}</div>
{/if}
<form onsubmit={handleSubmit} class="max-w-2xl mx-auto">
<div class="mb-4">
<label for="url" class="block text-sm font-medium text-gray-700 mb-2">
Instagram Recipe URL
</label>
<input
type="url"
id="url"
bind:value={url}
placeholder="https://instagram.com/p/..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<button
type="submit"
disabled={!url.trim()}
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
Extract Recipe
</button>
</form>

201
src/service-worker.ts Normal file
View File

@@ -0,0 +1,201 @@
/// <reference types="vite/client" />
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
import { NavigationRoute, registerRoute } from 'workbox-routing';
declare let self: ServiceWorkerGlobalScope;
// PWA Workbox caching
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// Handle navigation requests
const handler = createHandlerBoundToURL('/');
const navigationRoute = new NavigationRoute(handler, {
denylist: [/^\/api/]
});
registerRoute(navigationRoute);
// Push notification handling
self.addEventListener('push', (event) => {
console.log('[SW] Push event received:', event);
if (!event.data) {
console.log('[SW] Push event but no data');
return;
}
let data;
try {
data = event.data.json();
} catch (e) {
console.error('[SW] Failed to parse push data:', e);
return;
}
console.log('[SW] Push data:', data);
const options: NotificationOptions = {
body: data.body || 'Recipe processing update',
icon: '/favicon.png',
badge: '/favicon.png',
data: data,
requireInteraction: data.requireInteraction || false,
silent: false,
tag: data.tag || 'recipe-update',
timestamp: Date.now(),
actions: []
};
// Add actions based on notification type
if (data.type === 'success' && data.itemId) {
options.actions = [
{
action: 'view',
title: 'View Recipe',
icon: '/favicon.png'
},
{
action: 'dismiss',
title: 'Dismiss'
}
];
} else if (data.type === 'error' && data.itemId) {
options.actions = [
{
action: 'retry',
title: 'Retry',
icon: '/favicon.png'
},
{
action: 'view',
title: 'View Details'
}
];
}
const title = data.title || getNotificationTitle(data.type, data);
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification click received:', event);
event.notification.close();
const data = event.notification.data;
const action = event.action;
let url = '/';
if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`;
} else if (action === 'retry' && data?.itemId) {
// Navigate to dashboard and trigger retry via postMessage
url = `/?highlight=${data.itemId}&action=retry`;
} else if (data?.itemId) {
url = `/?highlight=${data.itemId}`;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientsList) => {
// Check if there's already a window/tab open
for (const client of clientsList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus().then(() => {
// Send message to the client about the action
return client.postMessage({
type: 'notification-action',
action: action,
data: data
});
});
}
}
// If no window is open, open a new one
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Handle notification close
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event);
// Track notification dismissals if needed
const data = event.notification.data;
if (data?.analytics) {
// Could send analytics event here
console.log('[SW] Notification dismissed:', data);
}
});
// Background sync for retry operations
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'retry-queue-item') {
event.waitUntil(handleRetrySync());
}
});
// Helper functions
function getNotificationTitle(type: string, data: any): string {
switch (type) {
case 'success':
return data.recipeName
? `✅ Recipe Ready: ${data.recipeName}`
: '✅ Recipe extraction complete';
case 'error':
return '❌ Recipe extraction failed';
case 'progress':
return `🔄 Processing recipe...`;
default:
return '📱 InstaRecipe Update';
}
}
async function handleRetrySync() {
try {
// Get retry items from IndexedDB or localStorage if needed
console.log('[SW] Handling retry sync');
// This could implement background retry logic
// For now, we'll let the main app handle retries
return Promise.resolve();
} catch (error) {
console.error('[SW] Retry sync failed:', error);
throw error;
}
}
// Message handling for communication with main app
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data);
const { type, data } = event.data;
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'GET_VERSION':
event.ports[0].postMessage({ version: '1.0.0' });
break;
case 'QUEUE_RETRY':
// Queue a background sync for retry
self.registration.sync.register('retry-queue-item');
break;
default:
console.log('[SW] Unknown message type:', type);
}
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
/**
* Integration tests for thumbnail URL validation in the complete extraction flow

518
src/tests/queue-api.spec.ts Normal file
View File

@@ -0,0 +1,518 @@
/**
* Integration tests for Queue API endpoints
*
* Tests the HTTP API routes for queue operations by directly invoking the handlers.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { queueManager } from '$lib/server/queue/QueueManager';
import { POST as queuePOST, GET as queueGET } from '../routes/api/queue/+server.js';
import { GET as itemGET, DELETE as itemDELETE } from '../routes/api/queue/[id]/+server.js';
import { POST as retryPOST } from '../routes/api/queue/[id]/retry/+server.js';
describe('Queue API Endpoints', () => {
beforeEach(() => {
// Clear queue between tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
afterEach(() => {
// Clean up after tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
describe('POST /api/queue', () => {
it('should enqueue valid Instagram URL', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://instagram.com/p/ABC123'
})
});
const response = await queuePOST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBeTruthy();
expect(data.url).toBe('https://instagram.com/p/ABC123');
expect(data.status).toBe('pending');
expect(data.enqueuedAt).toBeTruthy();
// Verify item exists in queue
const item = queueManager.get(data.id);
expect(item).toBeTruthy();
expect(item?.url).toBe('https://instagram.com/p/ABC123');
});
it('should accept Instagram URLs with www', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://www.instagram.com/p/XYZ789'
})
});
const response = await queuePOST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe('https://www.instagram.com/p/XYZ789');
// Verify item exists in queue
const item = queueManager.get(data.id);
expect(item).toBeTruthy();
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
});
it('should reject invalid Instagram URL formats', async () => {
const invalidUrls = [
'https://facebook.com/post/123',
'https://instagram.com/user/profile',
'not-a-url',
'https://other-site.com'
];
for (const url of invalidUrls) {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url })
});
try {
const response = await queuePOST({ request } as any);
// If we get here, check the response status
expect(response.status).toBe(400);
const data = await response.json();
expect(data.message).toBe('Invalid Instagram URL format. Expected: https://instagram.com/p/{post-id}');
} catch (err: any) {
// SvelteKit's error() throws - check the error
expect(err.status).toBe(400);
expect(err.body.message).toBe('Invalid Instagram URL format. Expected: https://instagram.com/p/{post-id}');
}
}
// Verify no items were added to queue
expect(queueManager.getAll()).toHaveLength(0);
});
it('should reject missing URL', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({})
});
try {
const response = await queuePOST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.message).toBe('URL is required and must be a string');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('URL is required and must be a string');
}
});
it('should reject non-JSON body', async () => {
const request = new Request('http://localhost/api/queue', {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: 'not json'
});
try {
const response = await queuePOST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.message).toBe('Invalid JSON in request body');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('Invalid JSON in request body');
}
});
});
describe('GET /api/queue', () => {
it('should return empty list when no items', async () => {
const url = new URL('http://localhost/api/queue');
const request = new Request(url);
const response = await queueGET({ request, url } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.items).toEqual([]);
expect(data.total).toBe(0);
expect(data.pagination.offset).toBe(0);
expect(data.pagination.limit).toBe(50);
});
it('should return queued items', async () => {
// Add test items
const item1 = queueManager.enqueue('https://instagram.com/p/TEST1');
const item2 = queueManager.enqueue('https://instagram.com/p/TEST2');
const url = new URL('http://localhost/api/queue');
const request = new Request(url);
const response = await queueGET({ request, url } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.total).toBe(2);
expect(data.items).toHaveLength(2);
expect(data.items[0].url).toBe('https://instagram.com/p/TEST1');
expect(data.items[1].url).toBe('https://instagram.com/p/TEST2');
});
it('should filter by status', async () => {
// Add test items with different statuses
const item1 = queueManager.enqueue('https://instagram.com/p/PENDING');
const item2 = queueManager.enqueue('https://instagram.com/p/ERROR');
// Set one to error status
queueManager.updateStatus(item2.id, 'error', { message: 'Test error' });
const url = new URL('http://localhost/api/queue?status=error');
const request = new Request(url);
const response = await queueGET({ request, url } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.total).toBe(1);
expect(data.items).toHaveLength(1);
expect(data.items[0].status).toBe('error');
expect(data.items[0].url).toBe('https://instagram.com/p/ERROR');
});
it('should handle pagination', async () => {
// Add multiple test items
for (let i = 1; i <= 5; i++) {
queueManager.enqueue(`https://instagram.com/p/TEST${i}`);
}
const url = new URL('http://localhost/api/queue?limit=2&offset=1');
const request = new Request(url);
const response = await queueGET({ request, url } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.total).toBe(5);
expect(data.items).toHaveLength(2);
expect(data.pagination.offset).toBe(1);
expect(data.pagination.limit).toBe(2);
// Items are sorted by enqueued time (newest first), so with offset=1, limit=2 we get items 2-3 from the sorted list
});
it('should validate query parameters', async () => {
// Invalid status
try {
let url = new URL('http://localhost/api/queue?status=invalid');
let request = new Request(url);
let response = await queueGET({ request, url } as any);
expect(response.status).toBe(400);
let data = await response.json();
expect(data.message).toBe('Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('Invalid status filter. Must be one of: pending, in_progress, success, unhealthy, error');
}
// Invalid limit (negative)
try {
let url = new URL('http://localhost/api/queue?limit=-1');
let request = new Request(url);
let response = await queueGET({ request, url } as any);
expect(response.status).toBe(400);
let data = await response.json();
expect(data.message).toBe('Limit must be a positive integer');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('Limit must be a positive integer');
}
// Invalid offset (negative)
try {
let url = new URL('http://localhost/api/queue?offset=-1');
let request = new Request(url);
let response = await queueGET({ request, url } as any);
expect(response.status).toBe(400);
let data = await response.json();
expect(data.message).toBe('Offset must be a non-negative integer');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('Offset must be a non-negative integer');
}
// Limit too large
try {
let url = new URL('http://localhost/api/queue?limit=999');
let request = new Request(url);
let response = await queueGET({ request, url } as any);
expect(response.status).toBe(400);
let data = await response.json();
expect(data.message).toBe('Limit cannot exceed 200');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('Limit cannot exceed 200');
}
});
});
describe('GET /api/queue/[id]', () => {
it('should return queue item by ID', async () => {
// Add test item
const item = queueManager.enqueue('https://instagram.com/p/DETAIL');
const response = await itemGET({
params: { id: item.id }
} as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBe(item.id);
expect(data.url).toBe('https://instagram.com/p/DETAIL');
expect(data.status).toBe('pending');
});
it('should return 404 for non-existent ID', async () => {
const fakeId = '550e8400-e29b-41d4-a716-446655440000'; // Valid v4 UUID format but non-existent
try {
const response = await itemGET({
params: { id: fakeId }
} as any);
expect(response.status).toBe(404);
const data = await response.json();
expect(data.message).toBe('Queue item not found');
} catch (err: any) {
expect(err.status).toBe(404);
expect(err.body.message).toBe('Queue item not found');
}
});
it('should validate ID format', async () => {
try {
const response = await itemGET({
params: { id: 'invalid-id' }
} as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.message).toBe('Invalid queue item ID format');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('Invalid queue item ID format');
}
});
});
describe('POST /api/queue/[id]/retry', () => {
it('should retry error item', async () => {
// Add test item and set to error
const item = queueManager.enqueue('https://instagram.com/p/RETRY');
queueManager.updateStatus(item.id, 'error', { message: 'Test error' });
const request = new Request(`http://localhost/api/queue/${item.id}/retry`, {
method: 'POST'
});
const response = await retryPOST({
request,
params: { id: item.id }
} as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.message).toBe('Queue item has been reset and will be reprocessed');
expect(data.success).toBe(true);
// Verify item status was reset
const updatedItem = queueManager.get(item.id);
expect(updatedItem?.status).toBe('pending');
expect(updatedItem?.error).toBeUndefined(); // error field is cleared (undefined, not null)
});
it('should retry unhealthy item', async () => {
// Add test item and set to unhealthy
const item = queueManager.enqueue('https://instagram.com/p/UNHEALTHY');
queueManager.updateStatus(item.id, 'unhealthy', {
phase: 'extraction',
attempts: 3,
lastAttempt: new Date(),
message: 'Max retries exceeded'
});
const request = new Request(`http://localhost/api/queue/${item.id}/retry`, {
method: 'POST'
});
const response = await retryPOST({
request,
params: { id: item.id }
} as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.message).toBe('Queue item has been reset and will be reprocessed');
expect(data.success).toBe(true);
// Verify item status was reset
const updatedItem = queueManager.get(item.id);
expect(updatedItem?.status).toBe('pending');
});
it('should reject retry for non-retryable statuses', async () => {
// Add test item (default status is 'pending')
const item = queueManager.enqueue('https://instagram.com/p/PENDING');
const request = new Request(`http://localhost/api/queue/${item.id}/retry`, {
method: 'POST'
});
// Item is pending (cannot retry)
try {
const response = await retryPOST({
request,
params: { id: item.id }
} as any);
expect(response.status).toBe(409);
const data = await response.json();
expect(data.message).toBe("Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried.");
} catch (err: any) {
expect(err.status).toBe(409);
expect(err.body.message).toBe("Cannot retry item with status 'pending'. Only 'error' and 'unhealthy' items can be retried.");
}
});
it('should return 404 for non-existent item', async () => {
const fakeId = '550e8400-e29b-41d4-a716-446655440000'; // Valid v4 UUID format but non-existent
const request = new Request(`http://localhost/api/queue/${fakeId}/retry`, {
method: 'POST'
});
try {
const response = await retryPOST({
request,
params: { id: fakeId }
} as any);
expect(response.status).toBe(404);
const data = await response.json();
expect(data.message).toBe('Queue item not found');
} catch (err: any) {
expect(err.status).toBe(404);
expect(err.body.message).toBe('Queue item not found');
}
});
});
describe('DELETE /api/queue/[id]', () => {
it('should delete queue item successfully', async () => {
// Create an item
const item = queueManager.enqueue('https://instagram.com/p/DELETE123');
// Mark it as success (completed)
queueManager.updateStatus(item.id, 'success');
const request = new Request(`http://localhost/api/queue/${item.id}`, {
method: 'DELETE'
});
const response = await itemDELETE({
request,
params: { id: item.id }
} as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.message).toBe('Queue item removed successfully');
// Verify item no longer exists
expect(queueManager.get(item.id)).toBeUndefined();
});
it('should return 404 for non-existent item', async () => {
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
const request = new Request(`http://localhost/api/queue/${fakeId}`, {
method: 'DELETE'
});
try {
const response = await itemDELETE({
request,
params: { id: fakeId }
} as any);
expect(response.status).toBe(404);
const data = await response.json();
expect(data.message).toBe('Queue item not found');
} catch (err: any) {
expect(err.status).toBe(404);
expect(err.body.message).toBe('Queue item not found');
}
});
it('should return 409 for in-progress items', async () => {
// Create an item and mark it as in progress
const item = queueManager.enqueue('https://instagram.com/p/PROCESSING');
queueManager.updateStatus(item.id, 'in_progress', { phase: 'extraction' });
const request = new Request(`http://localhost/api/queue/${item.id}`, {
method: 'DELETE'
});
try {
const response = await itemDELETE({
request,
params: { id: item.id }
} as any);
expect(response.status).toBe(409);
const data = await response.json();
expect(data.message).toBe('Cannot delete item that is currently being processed');
} catch (err: any) {
expect(err.status).toBe(409);
expect(err.body.message).toBe('Cannot delete item that is currently being processed');
}
// Verify item still exists
expect(queueManager.get(item.id)).toBeTruthy();
});
it('should validate ID format', async () => {
const invalidIds = ['not-a-uuid', '12345', 'abc-def-ghi'];
for (const invalidId of invalidIds) {
const request = new Request(`http://localhost/api/queue/${invalidId}`, {
method: 'DELETE'
});
try {
const response = await itemDELETE({
request,
params: { id: invalidId }
} as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.message).toBe('Invalid queue item ID format');
} catch (err: any) {
expect(err.status).toBe(400);
expect(err.body.message).toBe('Invalid queue item ID format');
}
}
});
});
});

View File

@@ -0,0 +1,356 @@
/**
* Unit tests for QueueManager
*
* Tests core queue operations, status management, and pub/sub functionality.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { QueueManager } from '$lib/server/queue/QueueManager';
describe('QueueManager', () => {
let queueManager: QueueManager;
beforeEach(() => {
// Create fresh instance for each test
queueManager = new QueueManager();
});
describe('enqueue', () => {
it('should enqueue items with unique IDs', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
expect(item1.id).toBeTruthy();
expect(item2.id).toBeTruthy();
expect(item1.id).not.toBe(item2.id);
});
it('should create items with pending status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(item.status).toBe('pending');
expect(item.enqueuedAt).toBeTruthy();
expect(item.logs).toEqual([]);
expect(item.progressEvents).toEqual([]);
expect(item.retryCount).toBe(0);
expect(item.maxRetries).toBe(3);
});
it('should notify subscribers when enqueueing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
status: 'pending'
})
);
});
});
describe('dequeue', () => {
it('should dequeue oldest pending item first (FIFO)', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
const dequeued1 = queueManager.dequeue();
expect(dequeued1?.id).toBe(item1.id);
const dequeued2 = queueManager.dequeue();
expect(dequeued2?.id).toBe(item2.id);
});
it('should return null when queue is empty', () => {
const item = queueManager.dequeue();
expect(item).toBeNull();
});
it('should mark dequeued item as in_progress', () => {
const enqueuedItem = queueManager.enqueue('https://instagram.com/p/test');
const dequeuedItem = queueManager.dequeue();
expect(dequeuedItem?.status).toBe('in_progress');
expect(dequeuedItem?.currentPhase).toBe('extraction');
expect(dequeuedItem?.startedAt).toBeTruthy();
});
it('should skip non-pending items', () => {
const item1 = queueManager.enqueue('https://instagram.com/p/test1');
const item2 = queueManager.enqueue('https://instagram.com/p/test2');
// Dequeue first item
queueManager.dequeue();
// Second item should be next
const dequeued = queueManager.dequeue();
expect(dequeued?.id).toBe(item2.id);
});
});
describe('updateStatus', () => {
it('should update item status', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress', { phase: 'parsing' });
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('in_progress');
expect(updated?.currentPhase).toBe('parsing');
});
it('should set completedAt for terminal statuses', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success');
const updated = queueManager.get(item.id);
expect(updated?.completedAt).toBeTruthy();
});
it('should merge additional data into item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'success', {
recipe: { name: 'Test Recipe' },
tandoorRecipeId: 123
});
const updated = queueManager.get(item.id);
expect(updated?.recipe).toEqual({ name: 'Test Recipe' });
expect(updated?.tandoorRecipeId).toBe(123);
});
it('should handle error data', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const errorData = {
error: {
phase: 'extraction' as const,
message: 'Failed to load page',
recoverable: true,
timestamp: new Date().toISOString()
}
};
queueManager.updateStatus(item.id, 'unhealthy', errorData);
const updated = queueManager.get(item.id);
expect(updated?.error).toEqual(errorData.error);
});
});
describe('addProgressEvent', () => {
it('should add progress events to item', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const event = {
type: 'status',
message: 'Extracting...',
timestamp: new Date().toISOString()
};
queueManager.addProgressEvent(item.id, event);
const updated = queueManager.get(item.id);
expect(updated?.progressEvents).toHaveLength(1);
expect(updated?.progressEvents[0]).toEqual(event);
});
it('should add event message to logs', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Test message',
timestamp: new Date().toISOString()
});
const updated = queueManager.get(item.id);
expect(updated?.logs).toContain('Test message');
});
it('should notify subscribers with event data', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear(); // Clear enqueue notification
const event = { type: 'status', message: 'Test', timestamp: new Date().toISOString() };
queueManager.addProgressEvent(item.id, event);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { event }
})
);
});
});
describe('remove', () => {
it('should remove items by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const removed = queueManager.remove(item.id);
expect(removed).toBe(true);
expect(queueManager.get(item.id)).toBeUndefined();
});
it('should return false for non-existent items', () => {
const removed = queueManager.remove('non-existent-id');
expect(removed).toBe(false);
});
it('should notify subscribers when removing', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
const item = queueManager.enqueue('https://instagram.com/p/test');
callback.mockClear();
queueManager.remove(item.id);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
itemId: item.id,
data: { removed: true }
})
);
});
});
describe('retry', () => {
it('should retry failed items', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
const retried = queueManager.retry(item.id);
expect(retried).toBe(true);
const updated = queueManager.get(item.id);
expect(updated?.status).toBe('pending');
expect(updated?.retryCount).toBe(1);
expect(updated?.error).toBeUndefined();
expect(updated?.currentPhase).toBeUndefined();
});
it('should not retry items in progress', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'in_progress');
const retried = queueManager.retry(item.id);
expect(retried).toBe(false);
expect(queueManager.get(item.id)?.status).toBe('in_progress');
});
it('should increment retry count', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
queueManager.updateStatus(item.id, 'error');
queueManager.retry(item.id);
queueManager.retry(item.id);
expect(queueManager.get(item.id)?.retryCount).toBe(2);
});
});
describe('getAll', () => {
it('should return all queue items', () => {
queueManager.enqueue('https://instagram.com/p/test1');
queueManager.enqueue('https://instagram.com/p/test2');
queueManager.enqueue('https://instagram.com/p/test3');
const items = queueManager.getAll();
expect(items).toHaveLength(3);
});
it('should return empty array when queue is empty', () => {
const items = queueManager.getAll();
expect(items).toEqual([]);
});
});
describe('get', () => {
it('should return item by ID', () => {
const item = queueManager.enqueue('https://instagram.com/p/test');
const retrieved = queueManager.get(item.id);
expect(retrieved?.id).toBe(item.id);
expect(retrieved?.url).toBe(item.url);
});
it('should return undefined for non-existent ID', () => {
const item = queueManager.get('non-existent-id');
expect(item).toBeUndefined();
});
});
describe('subscribe', () => {
it('should notify subscribers of updates', () => {
const callback = vi.fn();
queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test');
expect(callback).toHaveBeenCalled();
});
it('should return unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = queueManager.subscribe(callback);
queueManager.enqueue('https://instagram.com/p/test1');
expect(callback).toHaveBeenCalledTimes(1);
unsubscribe();
callback.mockClear();
queueManager.enqueue('https://instagram.com/p/test2');
expect(callback).not.toHaveBeenCalled();
});
it('should handle subscriber errors gracefully', () => {
const goodCallback = vi.fn();
const badCallback = vi.fn(() => {
throw new Error('Subscriber error');
});
queueManager.subscribe(goodCallback);
queueManager.subscribe(badCallback);
// Should not throw despite bad callback
expect(() => {
queueManager.enqueue('https://instagram.com/p/test');
}).not.toThrow();
// Good callback should still be called
expect(goodCallback).toHaveBeenCalled();
});
it('should support multiple subscribers', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
queueManager.subscribe(callback1);
queueManager.subscribe(callback2);
queueManager.subscribe(callback3);
queueManager.enqueue('https://instagram.com/p/test');
expect(callback1).toHaveBeenCalled();
expect(callback2).toHaveBeenCalled();
expect(callback3).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,250 @@
/**
* Integration tests for QueueProcessor
*
* Tests the processor's ability to handle queue items through mocked dependencies.
* The QueueProcessor auto-starts, so these tests verify actual processing behavior.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { queueManager } from '$lib/server/queue/QueueManager';
// Mock queueConfig BEFORE importing QueueProcessor
vi.mock('$lib/server/queue/config', () => ({
queueConfig: {
concurrency: 2,
maxRetries: 3,
tandoor: {
enabled: true,
token: 'test-token',
serverUrl: 'http://localhost:8080'
},
push: {
vapidPublicKey: 'test-public-key',
vapidPrivateKey: 'test-private-key'
}
}
}));
// Mock external dependencies BEFORE importing QueueProcessor
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
})
}));
vi.mock('$lib/server/parser', () => ({
extractRecipe: vi.fn().mockResolvedValue({
name: 'Default Recipe',
ingredients: ['ingredient 1'],
steps: ['step 1'],
description: 'A default recipe'
})
}));
vi.mock('$lib/server/tandoor', () => ({
uploadRecipeWithIngredientsDTO: vi.fn().mockResolvedValue({
success: true,
recipeId: 999
}),
uploadRecipeImage: vi.fn().mockResolvedValue({
success: true
})
}));
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
import * as configModule from '$lib/server/queue/config';
// Import processor AFTER mocks - it will auto-start (imported for side effects)
import '$lib/server/queue/QueueProcessor';
describe('QueueProcessor Integration Tests', () => {
beforeEach(async () => {
// Clear queue
queueManager.getAll().forEach(item => queueManager.remove(item.id));
// Reset mocks and their implementations
vi.resetAllMocks();
// Set default mock implementations
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Default Recipe',
ingredients: ['ingredient 1'],
steps: ['step 1'],
description: 'A default recipe'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 999
});
vi.mocked(uploadRecipeImage).mockResolvedValue({
success: true
});
});
afterEach(async () => {
// Wait for any pending processing to complete
await new Promise((resolve) => setTimeout(resolve, 100));
});
it('should process item through all phases when Tandoor is configured', async () => {
// Set up successful mocks
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe instructions here',
thumbnail: 'https://example.com/thumb.jpg'
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Test Recipe',
ingredients: ['flour', 'eggs'],
steps: ['mix', 'bake'],
description: 'test'
});
vi.mocked(uploadRecipeWithIngredientsDTO).mockResolvedValue({
success: true,
recipeId: 123
});
// Enqueue (processor is already running from auto-start)
// Note: Tandoor is enabled in the mocked config
const item = queueManager.enqueue('https://instagram.com/p/test-tandoor');
// Wait for processing to complete - increased timeout
await new Promise((resolve) => setTimeout(resolve, 1000));
const updated = queueManager.get(item.id);
// Verify success
expect(updated?.status).toBe('success');
expect(updated?.extractedText).toBe('Recipe instructions here');
expect(updated?.recipe?.name).toBe('Test Recipe');
expect(updated?.tandoorRecipeId).toBe(123);
// Verify all functions were called
expect(extractTextAndThumbnail).toHaveBeenCalled();
expect(extractRecipe).toHaveBeenCalled();
expect(uploadRecipeWithIngredientsDTO).toHaveBeenCalled();
}, 10000); // Increase timeout for processing
it('should skip Tandoor upload when not configured', async () => {
// Temporarily disable Tandoor for this test
const originalConfig = { ...configModule.queueConfig };
vi.spyOn(configModule, 'queueConfig', 'get').mockReturnValue({
...originalConfig,
tandoor: {
enabled: false,
token: null,
serverUrl: null
}
});
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Recipe text',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'No Tandoor Recipe',
ingredients: [],
steps: [],
description: ''
});
const item = queueManager.enqueue('https://instagram.com/p/no-tandoor');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should still succeed without Tandoor
expect(updated?.status).toBe('success');
expect(updated?.recipe?.name).toBe('No Tandoor Recipe');
expect(uploadRecipeWithIngredientsDTO).not.toHaveBeenCalled();
// Restore mock
vi.restoreAllMocks();
}, 10000);
it('should handle extraction errors', async () => {
vi.mocked(extractTextAndThumbnail).mockRejectedValue(
new Error('Network timeout')
);
const item = queueManager.enqueue('https://instagram.com/p/error');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as unhealthy (recoverable)
expect(updated?.status).toBe('unhealthy');
expect(updated?.error?.message).toContain('timeout');
}, 10000);
it('should handle parsing failure', async () => {
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
bodyText: 'Not a recipe',
thumbnail: null
});
vi.mocked(extractRecipe).mockResolvedValue(null);
const item = queueManager.enqueue('https://instagram.com/p/not-recipe');
await new Promise((resolve) => setTimeout(resolve, 800));
const updated = queueManager.get(item.id);
// Should mark as error (non-recoverable - no recipe found)
expect(updated?.status).toBe('error');
expect(updated?.error?.message).toContain('recipe');
}, 10000);
it('should process multiple items respecting concurrency', async () => {
// Set up mocks with delay to observe concurrency
vi.mocked(extractTextAndThumbnail).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return { bodyText: 'text', thumbnail: null };
});
vi.mocked(extractRecipe).mockResolvedValue({
name: 'Concurrent Recipe',
ingredients: [],
steps: [],
description: ''
});
// Enqueue 3 items (Tandoor enabled by default in config mock)
queueManager.enqueue('https://instagram.com/p/item1');
queueManager.enqueue('https://instagram.com/p/item2');
queueManager.enqueue('https://instagram.com/p/item3');
// Wait a bit for processor to start working
await new Promise((resolve) => setTimeout(resolve, 150));
const items = queueManager.getAll();
const inProgress = items.filter(i => i.status === 'in_progress');
// With concurrency=2, should have max 2 in progress at once
expect(inProgress.length).toBeLessThanOrEqual(2);
// Wait for all to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
const final = queueManager.getAll();
const completed = final.filter(i => i.status === 'success');
// All 3 should eventually complete
expect(completed.length).toBe(3);
}, 15000);
});

141
src/tests/queue-sse.spec.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* Integration tests for Queue SSE Stream endpoint
*
* Tests the Server-Sent Events stream for real-time queue updates.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { queueManager } from '$lib/server/queue/QueueManager';
import { GET as streamGET } from '../routes/api/queue/stream/+server.js';
describe('Queue SSE Stream Endpoint', () => {
beforeEach(() => {
// Clear queue between tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
afterEach(() => {
// Clean up after tests
queueManager.getAll().forEach(item => queueManager.remove(item.id));
});
describe('GET /api/queue/stream', () => {
it('should return SSE response with correct headers', async () => {
const url = new URL('http://localhost/api/queue/stream');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
expect(response.headers.get('Connection')).toBe('keep-alive');
});
it('should reject invalid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=invalid');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toContain('Invalid status filter');
});
it('should reject invalid item ID format', async () => {
const url = new URL('http://localhost/api/queue/stream?id=invalid-id');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(400);
const text = await response.text();
expect(text).toBe('Invalid queue item ID format');
});
it('should accept valid status filter', async () => {
const url = new URL('http://localhost/api/queue/stream?status=pending');
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should accept valid item ID filter', async () => {
// Add a test item first
const item = queueManager.enqueue('https://instagram.com/p/TEST123');
const url = new URL(`http://localhost/api/queue/stream?id=${item.id}`);
const request = new Request(url);
const response = await streamGET({
url,
request: {
...request,
signal: new AbortController().signal
}
} as any);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
});
it('should handle stream initialization without errors', async () => {
// Add some test items
queueManager.enqueue('https://instagram.com/p/TEST1');
queueManager.enqueue('https://instagram.com/p/TEST2');
const url = new URL('http://localhost/api/queue/stream');
const abortController = new AbortController();
const request = new Request(url, {
signal: abortController.signal
});
const response = await streamGET({
url,
request
} as any);
expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(ReadableStream);
// Abort the request to clean up
abortController.abort();
});
});
// Note: Full SSE stream testing would require more complex setup with
// ReadableStream readers and async iteration, which is beyond the scope
// of these basic endpoint validation tests. The above tests verify that:
// 1. The endpoint responds correctly
// 2. Headers are set properly for SSE
// 3. Parameter validation works
// 4. Stream initialization succeeds
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'path';
import fs from 'fs';

View File

@@ -11,14 +11,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
* - Handles network errors gracefully
*/
// Mock types matching the actual implementation
type ProgressCallback = (event: {
type: string;
message: string;
timestamp: string;
data?: any;
}) => void;
describe('fetchImageAsBase64 URL Validation', () => {
let originalFetch: typeof globalThis.fetch;
let mockProgressCallback: ReturnType<typeof vi.fn>;