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