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

View File

@@ -1,12 +1,12 @@
/**
* 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
*/
@@ -31,7 +31,7 @@ export const POST: RequestHandler = async ({ request }) => {
removedIn: 'v2.0.0'
}
},
{
{
status: 410, // 410 Gone - resource no longer available
headers: {
'X-Deprecated': 'true',
@@ -40,4 +40,4 @@ export const POST: RequestHandler = async ({ request }) => {
}
}
);
};
};

View File

@@ -1,11 +1,11 @@
/**
* Health Check API Endpoint
*
*
* Provides status information about critical application services:
* - Queue processing status
* - Queue statistics (pending, in_progress, etc.)
* - Server uptime information
*
*
* Used for monitoring and debugging queue processor functionality.
*/
@@ -14,48 +14,51 @@ import { queueManager } from '$lib/server/queue/QueueManager';
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
export const GET = async () => {
try {
// Get current queue items by status
const allItems = queueManager.getAll();
const statusCounts = {
pending: allItems.filter(item => item.status === 'pending').length,
in_progress: allItems.filter(item => item.status === 'in_progress').length,
success: allItems.filter(item => item.status === 'success').length,
error: allItems.filter(item => item.status === 'error').length,
unhealthy: allItems.filter(item => item.status === 'unhealthy').length
};
const stats = {
total: allItems.length
};
try {
// Get current queue items by status
const allItems = queueManager.getAll();
const statusCounts = {
pending: allItems.filter((item) => item.status === 'pending').length,
in_progress: allItems.filter((item) => item.status === 'in_progress').length,
success: allItems.filter((item) => item.status === 'success').length,
error: allItems.filter((item) => item.status === 'error').length,
unhealthy: allItems.filter((item) => item.status === 'unhealthy').length
};
const healthData = {
timestamp: new Date().toISOString(),
status: 'healthy',
services: {
queueProcessor: {
status: 'running', // QueueProcessor auto-starts, so it's always running
description: 'Queue processing service is operational'
},
queueManager: {
status: 'healthy',
stats,
statusCounts
}
},
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown'
};
const stats = {
total: allItems.length
};
return json(healthData);
} catch (error) {
console.error('[Health Check] Error retrieving health status:', error);
return json({
timestamp: new Date().toISOString(),
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
uptime: process.uptime()
}, { status: 500 });
}
};
const healthData = {
timestamp: new Date().toISOString(),
status: 'healthy',
services: {
queueProcessor: {
status: 'running', // QueueProcessor auto-starts, so it's always running
description: 'Queue processing service is operational'
},
queueManager: {
status: 'healthy',
stats,
statusCounts
}
},
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown'
};
return json(healthData);
} catch (error) {
console.error('[Health Check] Error retrieving health status:', error);
return json(
{
timestamp: new Date().toISOString(),
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
uptime: process.uptime()
},
{ status: 500 }
);
}
};

View File

@@ -10,21 +10,27 @@ export async function GET() {
const isHealthy = await checkLLMHealth();
if (isHealthy) {
return json({
status: 'healthy',
message: 'LLM service is accessible'
return json({
status: 'healthy',
message: 'LLM service is accessible'
});
} else {
return json({
status: 'unhealthy',
message: 'LLM service is not accessible'
}, { status: 503 });
return json(
{
status: 'unhealthy',
message: 'LLM service is not accessible'
},
{ status: 503 }
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return json({
status: 'error',
message: errorMessage
}, { status: 500 });
return json(
{
status: 'error',
message: errorMessage
},
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
/**
* Push Notification Subscription API
*
*
* Handles web push notification subscription/unsubscription
* for queue processing updates.
*/
@@ -11,9 +11,9 @@ import type { RequestHandler } from './$types.js';
/**
* Subscribe to push notifications
*
*
* POST /api/notifications/subscribe
*
*
* Body:
* {
* "subscription": {
@@ -27,87 +27,70 @@ import type { RequestHandler } from './$types.js';
* }
*/
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 }
);
}
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 }
);
}
};
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

@@ -1,6 +1,6 @@
/**
* Test Push Notification API
*
*
* Allows manual testing of push notifications with different payloads.
* Sends notification to all subscribed clients.
*/
@@ -11,71 +11,69 @@ import type { RequestHandler } from './$types.js';
/**
* Send test push notification
*
*
* POST /api/notifications/test
*
*
* Body:
* {
* "type": "success" | "error" | "progress"
* }
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const { type } = await request.json();
if (!type || !['success', 'error', 'progress'].includes(type)) {
return json(
{ error: 'Invalid notification type. Must be: success, error, or progress' },
{ status: 400 }
);
}
const testItemId = 'test_' + Date.now();
// Create test payloads for each type
const payloads = {
success: {
type: 'success' as const,
itemId: testItemId,
body: 'Test recipe extraction completed successfully!',
recipeName: 'Test Recipe',
tag: `recipe-success-${testItemId}`,
requireInteraction: false
},
error: {
type: 'error' as const,
itemId: testItemId,
body: 'Test recipe extraction failed - this is a test error',
tag: `recipe-error-${testItemId}`,
requireInteraction: true
},
progress: {
type: 'progress' as const,
itemId: testItemId,
body: 'Test recipe extraction in progress: parsing phase',
tag: `recipe-progress-${testItemId}`,
requireInteraction: false
}
};
const payload = payloads[type as keyof typeof payloads];
await pushNotificationService.sendNotification(payload);
console.log(`[NotificationTestAPI] Sent test ${type} notification`);
return json({
success: true,
message: `Test ${type} notification sent`,
subscriberCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error('[NotificationTestAPI] Error sending test notification:',
error instanceof Error ? error.message : String(error));
return json(
{ error: 'Failed to send test notification' },
{ status: 500 }
);
}
try {
const { type } = await request.json();
if (!type || !['success', 'error', 'progress'].includes(type)) {
return json(
{ error: 'Invalid notification type. Must be: success, error, or progress' },
{ status: 400 }
);
}
const testItemId = 'test_' + Date.now();
// Create test payloads for each type
const payloads = {
success: {
type: 'success' as const,
itemId: testItemId,
body: 'Test recipe extraction completed successfully!',
recipeName: 'Test Recipe',
tag: `recipe-success-${testItemId}`,
requireInteraction: false
},
error: {
type: 'error' as const,
itemId: testItemId,
body: 'Test recipe extraction failed - this is a test error',
tag: `recipe-error-${testItemId}`,
requireInteraction: true
},
progress: {
type: 'progress' as const,
itemId: testItemId,
body: 'Test recipe extraction in progress: parsing phase',
tag: `recipe-progress-${testItemId}`,
requireInteraction: false
}
};
const payload = payloads[type as keyof typeof payloads];
await pushNotificationService.sendNotification(payload);
console.log(`[NotificationTestAPI] Sent test ${type} notification`);
return json({
success: true,
message: `Test ${type} notification sent`,
subscriberCount: pushNotificationService.getSubscriptionCount()
});
} catch (error) {
console.error(
'[NotificationTestAPI] Error sending test notification:',
error instanceof Error ? error.message : String(error)
);
return json({ error: 'Failed to send test notification' }, { status: 500 });
}
};

View File

@@ -1,6 +1,6 @@
/**
* VAPID Public Key API
*
*
* Returns the public key for web push notifications.
* Required by browsers to create push subscriptions.
*/
@@ -11,9 +11,9 @@ import type { RequestHandler } from './$types.js';
/**
* Get VAPID public key
*
*
* GET /api/notifications/vapid-key
*
*
* Response:
* {
* "publicKey": "BDummyPublicKeyForDevelopment",
@@ -21,26 +21,19 @@ import type { RequestHandler } from './$types.js';
* }
*/
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 }
);
}
};
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

@@ -1,6 +1,6 @@
/**
* 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
@@ -15,135 +15,133 @@ 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) {
throw new ValidationError('Invalid JSON in request body');
}
// Validate request body
if (!body || typeof body !== 'object') {
throw new ValidationError('Request body must be JSON object');
}
const { url } = body;
// Validate URL presence
if (!url || typeof url !== 'string') {
throw new ValidationError('URL is required and must be a string');
}
// Validate Instagram URL format using utility
const validation = validateInstagramUrl(url);
if (!validation.valid) {
throw new ValidationError(validation.error || 'Invalid Instagram URL');
}
// 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 (error) {
return handleApiError(error);
}
try {
// Parse JSON body with proper error handling
let body;
try {
body = await request.json();
} catch (jsonError) {
throw new ValidationError('Invalid JSON in request body');
}
// Validate request body
if (!body || typeof body !== 'object') {
throw new ValidationError('Request body must be JSON object');
}
const { url } = body;
// Validate URL presence
if (!url || typeof url !== 'string') {
throw new ValidationError('URL is required and must be a string');
}
// Validate Instagram URL format using utility
const validation = validateInstagramUrl(url);
if (!validation.valid) {
throw new ValidationError(validation.error || 'Invalid Instagram URL');
}
// 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 (error) {
return handleApiError(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) {
throw new ValidationError('Limit must be a positive integer');
}
if (parsedLimit > 200) {
throw new ValidationError('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) {
throw new ValidationError('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)) {
throw new ValidationError(
`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 (error) {
return handleApiError(error);
}
};
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) {
throw new ValidationError('Limit must be a positive integer');
}
if (parsedLimit > 200) {
throw new ValidationError('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) {
throw new ValidationError('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)) {
throw new ValidationError(
`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 (error) {
return handleApiError(error);
}
};

View File

@@ -1,6 +1,6 @@
/**
* 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
@@ -14,84 +14,80 @@ 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') {
throw new ValidationError('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)) {
throw new ValidationError('Invalid queue item ID format');
}
// Get queue item
const queueItem = queueManager.get(id);
if (!queueItem) {
throw new NotFoundError('Queue item not found');
}
// Return full item details
return json(queueItem);
} catch (error) {
return handleApiError(error);
}
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('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)) {
throw new ValidationError('Invalid queue item ID format');
}
// Get queue item
const queueItem = queueManager.get(id);
if (!queueItem) {
throw new NotFoundError('Queue item not found');
}
// Return full item details
return json(queueItem);
} catch (error) {
return handleApiError(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') {
throw new ValidationError('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)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
throw new ConflictError(
'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 (error) {
return handleApiError(error);
}
};
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('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)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Prevent deletion of in-progress items
if (existingItem.status === 'in_progress') {
throw new ConflictError('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 (error) {
return handleApiError(error);
}
};

View File

@@ -1,6 +1,6 @@
/**
* Queue Item Retry API Endpoint
*
*
* Provides HTTP interface for retrying failed queue items:
* - POST /api/queue/[id]/retry - Retry failed/unhealthy queue item
*/
@@ -13,58 +13,57 @@ 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') {
throw new ValidationError('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)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
throw new ConflictError(
`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
throw new Error('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 (error) {
return handleApiError(error);
}
};
try {
const { id } = params;
// Validate ID parameter
if (!id || typeof id !== 'string') {
throw new ValidationError('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)) {
throw new ValidationError('Invalid queue item ID format');
}
// Check if item exists
const existingItem = queueManager.get(id);
if (!existingItem) {
throw new NotFoundError('Queue item not found');
}
// Check if item can be retried
if (existingItem.status !== 'error' && existingItem.status !== 'unhealthy') {
throw new ConflictError(
`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
throw new Error('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 (error) {
return handleApiError(error);
}
};

View File

@@ -1,6 +1,6 @@
/**
* Queue SSE Stream API Endpoint
*
*
* Provides Server-Sent Events stream for real-time queue updates:
* - GET /api/queue/stream - Stream queue status updates
*/
@@ -11,209 +11,209 @@ 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' }
});
}
}
// Track stream state to prevent "Controller already closed" errors
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let keepAliveInterval: NodeJS.Timeout | null = null;
// Unified cleanup function - prevents double cleanup
const cleanup = () => {
if (isClosed) return; // Already cleaned up
isClosed = true;
console.log('[SSE] Cleaning up stream connection');
// Unsubscribe from queue updates
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
// Clear keep-alive interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
};
// Safe enqueue helper - checks stream state before enqueueing
const safeEnqueue = (controller: ReadableStreamDefaultController, message: string): boolean => {
if (isClosed) {
return false; // Stream already closed, don't attempt to enqueue
}
try {
controller.enqueue(new TextEncoder().encode(message));
return true;
} catch (error) {
// Controller closed or errored - clean up and mark as closed
console.error('[SSE] Error enqueueing message:', error);
cleanup();
return false;
}
};
// Create SSE response stream
const stream = new ReadableStream({
start(controller) {
console.log('[SSE] Stream started');
// Send initial connection message
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
if (!safeEnqueue(controller, connectionMsg)) {
return;
}
// 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) {
if (isClosed) break; // Stop if stream was closed
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`;
if (!safeEnqueue(controller, sseMessage)) {
break; // Stop if enqueue failed
}
}
} catch (error) {
console.error('[SSE] Error sending initial queue state:', error);
}
// Subscribe to queue updates
unsubscribe = queueManager.subscribe((update) => {
if (isClosed) return; // Don't process if already closed
// 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`;
safeEnqueue(controller, sseMessage);
}
});
// Keep-alive ping every 30 seconds
keepAliveInterval = setInterval(() => {
if (isClosed) {
// Stop pinging if closed
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
return;
}
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
if (!safeEnqueue(controller, pingMsg)) {
// Failed to send ping, clear interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
}
}, 30000);
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('[SSE] Client disconnected (abort signal)');
cleanup();
// Try to send disconnect message (may fail if already closed)
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
safeEnqueue(controller, disconnectMsg);
// Close the controller
try {
controller.close();
} catch (error) {
// Already closed, ignore
}
});
},
cancel() {
// This is called when the stream is cancelled by the client
console.log('[SSE] Stream cancelled by client');
cleanup();
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
// Connection header omitted - Node.js handles connection management automatically
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Expose-Headers': 'Content-Type'
}
});
};
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' }
});
}
}
// Track stream state to prevent "Controller already closed" errors
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let keepAliveInterval: NodeJS.Timeout | null = null;
// Unified cleanup function - prevents double cleanup
const cleanup = () => {
if (isClosed) return; // Already cleaned up
isClosed = true;
console.log('[SSE] Cleaning up stream connection');
// Unsubscribe from queue updates
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
// Clear keep-alive interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
};
// Safe enqueue helper - checks stream state before enqueueing
const safeEnqueue = (controller: ReadableStreamDefaultController, message: string): boolean => {
if (isClosed) {
return false; // Stream already closed, don't attempt to enqueue
}
try {
controller.enqueue(new TextEncoder().encode(message));
return true;
} catch (error) {
// Controller closed or errored - clean up and mark as closed
console.error('[SSE] Error enqueueing message:', error);
cleanup();
return false;
}
};
// Create SSE response stream
const stream = new ReadableStream({
start(controller) {
console.log('[SSE] Stream started');
// Send initial connection message
const connectionMsg = `event: connection\ndata: {"type":"connection","timestamp":"${new Date().toISOString()}","message":"Connected to queue stream"}\n\n`;
if (!safeEnqueue(controller, connectionMsg)) {
return;
}
// 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) {
if (isClosed) break; // Stop if stream was closed
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`;
if (!safeEnqueue(controller, sseMessage)) {
break; // Stop if enqueue failed
}
}
} catch (error) {
console.error('[SSE] Error sending initial queue state:', error);
}
// Subscribe to queue updates
unsubscribe = queueManager.subscribe((update) => {
if (isClosed) return; // Don't process if already closed
// 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`;
safeEnqueue(controller, sseMessage);
}
});
// Keep-alive ping every 30 seconds
keepAliveInterval = setInterval(() => {
if (isClosed) {
// Stop pinging if closed
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
return;
}
const pingMsg = `event: ping\ndata: {"type":"ping","timestamp":"${new Date().toISOString()}"}\n\n`;
if (!safeEnqueue(controller, pingMsg)) {
// Failed to send ping, clear interval
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
}
}, 30000);
// Handle client disconnect
request.signal.addEventListener('abort', () => {
console.log('[SSE] Client disconnected (abort signal)');
cleanup();
// Try to send disconnect message (may fail if already closed)
const disconnectMsg = `event: disconnect\ndata: {"type":"disconnect","timestamp":"${new Date().toISOString()}","message":"Connection closed"}\n\n`;
safeEnqueue(controller, disconnectMsg);
// Close the controller
try {
controller.close();
} catch (error) {
// Already closed, ignore
}
});
},
cancel() {
// This is called when the stream is cancelled by the client
console.log('[SSE] Stream cancelled by client');
cleanup();
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
// Connection header omitted - Node.js handles connection management automatically
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Expose-Headers': 'Content-Type'
}
});
};

View File

@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import {tandoorConfig} from '$lib/server/tandoor-config';
export async function GET() {
return json({...tandoorConfig, token: ''});
}
import { json } from '@sveltejs/kit';
import { tandoorConfig } from '$lib/server/tandoor-config';
export async function GET() {
return json({ ...tandoorConfig, token: '' });
}

View File

@@ -1,43 +1,43 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
export const POST: RequestHandler = async ({ request }) => {
const { recipe } = await request.json();
if (!recipe) {
return json({ error: 'No recipe provided' }, { status: 400 });
}
try {
const result = await uploadRecipeWithIngredientsDTO(recipe);
if (!result.success) {
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
}
// Upload image if available
let imageStatus = null;
if (result.recipeId && result.imageUrl) {
imageStatus = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageStatus.success) {
console.warn('Image upload failed, but recipe created:', imageStatus.error);
}
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId,
imageUpload: imageStatus?.success ? 'successful' : 'failed'
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
}
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
export const POST: RequestHandler = async ({ request }) => {
const { recipe } = await request.json();
if (!recipe) {
return json({ error: 'No recipe provided' }, { status: 400 });
}
try {
const result = await uploadRecipeWithIngredientsDTO(recipe);
if (!result.success) {
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
}
// Upload image if available
let imageStatus = null;
if (result.recipeId && result.imageUrl) {
imageStatus = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageStatus.success) {
console.warn('Image upload failed, but recipe created:', imageStatus.error);
}
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId,
imageUpload: imageStatus?.success ? 'successful' : 'failed'
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
};

View File

@@ -6,7 +6,7 @@ import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});