simplify
This commit is contained in:
@@ -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 }) => {
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: '' });
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user