Files
insta-recipe/docs/plans/FixEventSourceSSR.md
Giancarmine Salucci 8545744bb1 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
2025-12-22 03:00:29 +01:00

23 KiB

Execution Plan: Fix SSR Violations and SvelteKit Best Practices

Outcome Name

FixEventSourceSSRAndBestPractices

Problem Analysis

Primary Issue

ReferenceError: EventSource is not defined at /home/moze/Sources/insta-recipe/src/routes/+page.svelte:299:66

Root Cause

The code is accessing EventSource during server-side rendering (SSR), but EventSource is a browser-only Web API that doesn't exist in Node.js. Additionally, comprehensive codebase analysis revealed multiple SSR violations and SvelteKit anti-patterns.

Affected Files - Critical

  1. src/routes/+page.svelte - EventSource accessed at L299, L82 without browser guards
  2. src/routes/share/+page.svelte - $effect with side effects (calls process() function)
  3. src/routes/share/components/LlmHealthIndicator.svelte - $effect with setInterval (no browser guard)

Affected Files - Already Compliant (Good Examples)

  1. src/lib/client/PushNotificationManager.ts

    • Properly uses browser from $app/environment (L10)
    • Guards localStorage access (L296, L300)
    • Guards window.atob access (L318)
    • Guards navigator.serviceWorker access (L111)
  2. src/lib/client/ServiceWorkerMessageHandler.ts

    • All browser APIs properly used in client-only context
    • Not imported/used in SSR contexts

SvelteKit Best Practices (from llms-full.txt documentation)

1. Browser API Access

Pattern: Import browser from $app/environment and guard all browser-only APIs

import { browser } from '$app/environment';

if (browser) {
  // Browser-only code
}

Browser-only APIs to guard:

  • window.*
  • document.*
  • localStorage, sessionStorage
  • navigator.*
  • EventSource, WebSocket
  • location.*

2. Lifecycle Hooks

Pattern: onMount only runs in browser (built-in SSR guard)

import { onMount } from 'svelte';

onMount(() => {
  // Automatically browser-only
  // Still good practice to add explicit browser check for clarity
});

3. Runes and Reactivity

$effect gotcha: Effects run during SSR AND hydration. Must guard browser APIs!

$effect(() => {
  if (!browser) return;
  // Browser-only reactive code
});

$derived gotcha: Computed values run during SSR. Keep them pure!

// ✅ GOOD - pure computation
let doubled = $derived(count * 2);

// ❌ BAD - side effects in derived
let value = $derived(localStorage.getItem('key')); // SSR crash!

4. State Initialization

Pattern: Initialize with SSR-safe defaults, update in onMount

let data = $state<Data | null>(null);

onMount(() => {
  // Load browser-only data
  data = JSON.parse(localStorage.getItem('key') || 'null');
});

5. Static Constants

Gotcha: Accessing static properties of browser APIs causes SSR errors

// ❌ BAD - EventSource.OPEN doesn't exist in Node
if (eventSource?.readyState === EventSource.OPEN)

// ✅ GOOD - Use numeric constants or guard
if (browser && eventSource?.readyState === EventSource.OPEN)
// OR
if (eventSource?.readyState === 1) // EventSource.OPEN = 1

Codebase Analysis Results

Already Properly Guarded

  • PushNotificationManager.ts - Excellent example of SSR-safe patterns
  • ServiceWorkerMessageHandler.ts - Client-only, properly scoped
  • All API routes in src/routes/api/** - Server-only contexts
  • Service worker (service-worker.ts) - Runs in worker context only

⚠️ Needs Fixing

High Priority (Breaking SSR):

  1. +page.svelte (Queue Dashboard)

    • L299: eventSource?.readyState === EventSource.OPEN - No browser guard
    • L82: eventSource?.readyState === EventSource.CLOSED - No browser guard
    • L67: new EventSource() - Inside onMount but needs explicit guard
    • Missing browser import
  2. LlmHealthIndicator.svelte

    • L36-39: $effect with setInterval - No browser guard
    • Should use onMount instead for timer setup

Medium Priority (Anti-patterns): 3. share/+page.svelte

  • L22-25: $effect calling process() with side effects
  • Should use onMount with conditional logic instead
  • $effect is meant for synchronization, not side effects

📋 Not Issues (Clarifications)

  • setTimeout in components (L81 in +page.svelte, L53 in share/+page.svelte) - OK because inside onMount or event handlers
  • goto from $app/navigation - SSR-safe (SvelteKit handles this)
  • $page store from $app/stores - SSR-safe (available in both contexts)
  • Server-side code (lib/server/**) using browser automation - OK (different context, uses Puppeteer)

Stories

Stories

Story 1: Fix EventSource SSR in Queue Dashboard

As a developer
I want to guard all EventSource usage from SSR execution
So that the application doesn't crash with "EventSource is not defined"

Acceptance Criteria:

  • Import browser from $app/environment
  • Guard EventSource constructor in startSSEConnection()
  • Replace EventSource.OPEN constant with numeric value 1 or add browser guard
  • Replace EventSource.CLOSED constant with numeric value 2 or add browser guard
  • Connection status works correctly after hydration
  • No SSR errors in server logs

Technical Details:

Lines to fix:

  • L2: Add import { browser } from '$app/environment';
  • L67-68: Add browser guard before creating EventSource
  • L82: Change EventSource.CLOSED to 2 or guard with browser
  • L299: Change EventSource.OPEN to 1 or guard with browser

Implementation:

import { browser } from '$app/environment';

function startSSEConnection() {
  if (!browser) return; // ✅ Guard
  
  try {
    eventSource = new EventSource('/api/queue/stream');
    // ... rest
  }
}

// In reconnection logic (L82):
if (browser && eventSource?.readyState === 2) { // CLOSED = 2
  startSSEConnection();
}

// In template (L299):
<div class="w-2 h-2 rounded-full {browser && eventSource?.readyState === 1 ? 'bg-green-400' : 'bg-red-400'}"></div>

Files:


Story 2: Fix $effect Anti-pattern in Share Page

As a developer
I want to replace $effect side effects with onMount pattern
So that the code follows SvelteKit best practices

Acceptance Criteria:

  • Replace $effect with onMount for auto-processing shared URLs
  • No side effects in reactive expressions
  • Auto-processing still works when URL is shared
  • No unnecessary re-triggering

Technical Details:

According to SvelteKit documentation, $effect should be used for synchronization, not side effects like API calls. Use onMount instead.

Current problematic code (L22-25):

$effect(() => {
  if (targetUrl && status === 'idle') {
    process();
  }
});

Fixed code:

let hasProcessed = $state(false);

onMount(() => {
  if (targetUrl && !hasProcessed) {
    hasProcessed = true;
    process();
  }
});

Files:

SvelteKit Pattern Reference:

Use $effect for synchronizing derived state, DOM manipulation, or reactive cleanup. Use onMount for initialization, API calls, and browser-only setup.


Story 3: Fix setInterval SSR in LLM Health Indicator

As a developer
I want to guard setInterval from SSR execution
So that the component doesn't break during server rendering

Acceptance Criteria:

  • Add browser guard to $effect containing setInterval
  • Health polling only runs in browser
  • Component renders safely during SSR
  • Cleanup still works correctly

Technical Details:

Current code (L36-39):

$effect(() => {
  checkHealth(); // Initial check
  const interval = setInterval(checkHealth, pollInterval);
  return () => clearInterval(interval);
});

Fixed code:

$effect(() => {
  if (!browser) return; // ✅ SSR guard
  
  checkHealth(); // Initial check
  const interval = setInterval(checkHealth, pollInterval);
  return () => clearInterval(interval);
});

Better alternative - use onMount:

onMount(() => {
  checkHealth(); // Initial check
  const interval = setInterval(checkHealth, pollInterval);
  return () => clearInterval(interval);
});

Files:


Story 4: Add SSR Best Practices Documentation

As a developer
I want documentation on SSR best practices for this project
So that future development avoids these issues

Acceptance Criteria:

  • Create or update developer documentation
  • Include examples from the codebase
  • Reference SvelteKit official documentation
  • Add inline comments explaining SSR guards

Technical Details:

Create documentation covering:

  1. Browser API detection with $app/environment
  2. Lifecycle hook usage (onMount vs $effect)
  3. Common gotchas (static constants, timers, storage APIs)
  4. Good examples from our codebase (PushNotificationManager)

Files to create/update:

  • docs/SVELTEKIT_SSR_GUIDE.md (new)
  • Add inline comments to fixed files

Story 5: Comprehensive SSR Audit and Testing

As a developer
I want to verify no other SSR violations exist
So that the application is fully SSR-safe

Acceptance Criteria:

  • Manual SSR test: npm run build && npm run preview
  • Check server logs for any SSR errors
  • Test all routes with JavaScript disabled (progressive enhancement)
  • Verify hydration works correctly
  • No console warnings about hydration mismatches

Technical Details:

Testing checklist:

  • Build production bundle: npm run build
  • Preview production: npm run preview
  • Navigate to all routes
  • Check server console for errors
  • Verify SSE connection works
  • Test push notification UI
  • Test queue dashboard
  • Test share page with/without URL params

Search patterns to verify:

# Find any unguarded browser API usage
grep -r "window\." src/routes --include="*.svelte"
grep -r "document\." src/routes --include="*.svelte"
grep -r "localStorage" src/routes --include="*.svelte"
grep -r "navigator\." src/routes --include="*.svelte"

Known safe patterns:

  • API routes (src/routes/api/**) - server-only
  • Client libraries (src/lib/client/**) - properly guarded
  • Event handlers (onclick, onsubmit) - run client-side
  • onMount callbacks - run client-side

Implementation Plan

Phase 1: Critical Fixes (Blocks Production)

Priority: URGENT - Fixes SSR crashes

  1. Story 1 - Fix EventSource in Queue Dashboard

    • Add browser import
    • Guard EventSource creation
    • Fix static constant references
    • Test SSR rendering
  2. Story 3 - Fix setInterval in LLM Health Indicator

    • Add browser guard to $effect OR convert to onMount
    • Test component SSR

Estimated Time: 30 minutes
Testing: Build and preview, check server logs


Phase 2: Best Practices (Improves Code Quality)

Priority: HIGH - Fixes anti-patterns

  1. Story 2 - Fix $effect anti-pattern in Share Page
    • Replace $effect with onMount
    • Add processed flag to prevent re-runs
    • Test auto-processing behavior

Estimated Time: 20 minutes
Testing: Test share target flow


Phase 3: Validation & Documentation (Prevents Future Issues)

Priority: MEDIUM - Long-term maintainability

  1. Story 5 - Comprehensive SSR Audit

    • Run production build
    • Test all routes
    • Verify no SSR errors
  2. Story 4 - Documentation

    • Create SSR best practices guide
    • Add inline comments
    • Document patterns from PushNotificationManager

Estimated Time: 1 hour
Testing: Full regression test


Total Estimated Time

  • Critical fixes: 30 min
  • Best practices: 20 min
  • Validation & docs: 1 hour
  • Total: ~2 hours

Technical Specifications

SvelteKit Runes Reference

$state - Reactive State

let count = $state(0); // Simple state
let obj = $state({ name: 'Alice' }); // Deep reactive proxy
  • SSR-safe for primitive values
  • ⚠️ Don't initialize with browser APIs

$derived - Computed Values

let doubled = $derived(count * 2);
  • Runs during SSR
  • ⚠️ Must be pure (no side effects)
  • Don't access browser APIs

$effect - Reactive Side Effects

$effect(() => {
  // Runs during SSR AND hydration
  console.log('count changed:', count);
});
  • ⚠️ Runs in both SSR and browser
  • Use for synchronization
  • Not for initialization or API calls
  • Must guard browser APIs

onMount - Browser-Only Lifecycle

onMount(() => {
  // Only runs in browser
  return () => {
    // Cleanup
  };
});
  • Only runs in browser
  • Use for initialization
  • Use for browser API access

Browser API Constants

Some browser APIs expose static constants that don't exist during SSR:

EventSource:

  • EventSource.CONNECTING = 0
  • EventSource.OPEN = 1
  • EventSource.CLOSED = 2

Solutions:

// ❌ BAD - Crashes SSR
if (es.readyState === EventSource.OPEN)

// ✅ GOOD - Use numeric value
if (es.readyState === 1)

// ✅ GOOD - Guard access
if (browser && es.readyState === EventSource.OPEN)

WebSocket: Similar issue with WebSocket.OPEN, etc.

Dependencies

  • $app/environment - Built-in SvelteKit module
  • No new package dependencies required

Files to Modify

Critical (Phase 1):

  1. src/routes/+page.svelte - Queue dashboard
  2. src/routes/share/components/LlmHealthIndicator.svelte - Health indicator

Best Practices (Phase 2): 3. src/routes/share/+page.svelte - Share page

Documentation (Phase 3): 4. docs/SVELTEKIT_SSR_GUIDE.md - New file

Code Patterns Summary

Pattern 1: Browser API in Component State

<script lang="ts">
  import { browser } from '$app/environment';
  import { onMount } from 'svelte';
  
  let eventSource = $state<EventSource | null>(null);
  
  onMount(() => {
    if (browser) {
      eventSource = new EventSource('/api/stream');
    }
    
    return () => {
      eventSource?.close();
    };
  });
</script>

<!-- Use numeric constants or guard in template -->
<div>Status: {eventSource?.readyState === 1 ? 'Connected' : 'Disconnected'}</div>

Pattern 2: Timers and Intervals

<script lang="ts">
  import { onMount } from 'svelte';
  
  onMount(() => {
    const interval = setInterval(() => {
      // Polling logic
    }, 1000);
    
    return () => clearInterval(interval);
  });
</script>

Pattern 3: Auto-Processing on Mount

<script lang="ts">
  import { onMount } from 'svelte';
  
  let hasProcessed = $state(false);
  let data = $derived(computeData());
  
  onMount(() => {
    if (shouldProcess && !hasProcessed) {
      hasProcessed = true;
      process();
    }
  });
</script>

Risk Assessment

High Priority Risks

  • SSR/Hydration mismatch: If guards are inconsistent between server and client
    • Mitigation: Use numeric constants; avoid conditional rendering based on browser
    • Testing: Check for hydration warnings in console

Medium Priority Risks

  • Regression in auto-processing: Share page might not auto-process URLs

    • Mitigation: Thorough testing of share target flow
    • Testing: Test with Instagram share and manual URL input
  • Connection status flicker: Status indicator might show wrong state briefly

    • Mitigation: Initialize with sensible defaults
    • Testing: Watch for visual flicker on page load

Low Priority Risks

  • Performance: Minimal, browser checks are fast
  • Breaking changes: Unlikely, only fixing internal implementation

Testing Strategy

Unit Testing

  • Not applicable - these are integration-level fixes
  • Existing tests should continue to pass

Integration Testing

Manual testing required:

  1. SSR Testing:

    npm run build
    npm run preview
    # Check server console for errors
    # Navigate to all pages
    
  2. EventSource Connection:

    • Open queue dashboard
    • Check browser DevTools → Network → EventSource
    • Verify "Live updates" status indicator
    • Add queue item and verify real-time update
  3. Share Page:

    • Navigate to /share
    • Manually enter URL → should work
    • Share from Instagram → should auto-process
    • Check no duplicate processing
  4. LLM Health:

    • Check health indicator appears
    • Verify polling happens (check Network tab)
    • No SSR errors in console

Edge Cases

  • Server restart while client connected → Reconnection works
  • Network disconnection → Graceful degradation
  • JavaScript disabled → Progressive enhancement (no errors)
  • Multiple tabs open → Each maintains own connection

Hydration Testing

  • Disable JavaScript after SSR
  • Enable JavaScript and check hydration
  • Look for console warnings:
    • "Hydration failed"
    • "The server response doesn't match the client content"

Browser Compatibility

  • Modern browsers with EventSource support
  • Browsers without EventSource → Should show disconnected status (no crash)

Success Metrics

Must Have (Phase 1)

  1. No EventSource is not defined errors
  2. No setInterval is not defined errors
  3. Production build succeeds
  4. SSR renders without errors
  5. Live updates work in browser

Should Have (Phase 2)

  1. No $effect anti-patterns
  2. No hydration warnings
  3. Share page auto-processing works

Nice to Have (Phase 3)

  1. SSR best practices documentation
  2. Inline comments explaining patterns
  3. All routes tested and verified

Anti-Patterns to Avoid

Don't Do This

// 1. Browser API in $derived
let data = $derived(localStorage.getItem('key')); // SSR crash!

// 2. Side effects in $effect without guard
$effect(() => {
  fetch('/api/data'); // Runs during SSR!
});

// 3. Static constants without guard
if (ws.readyState === WebSocket.OPEN) // SSR crash!

// 4. Initialization in $effect
$effect(() => {
  // Use onMount instead
  initialize();
});

Do This Instead

// 1. Load in onMount
let data = $state<string | null>(null);
onMount(() => {
  data = localStorage.getItem('key');
});

// 2. Guard browser APIs in $effect
$effect(() => {
  if (!browser) return;
  fetch('/api/data');
});

// 3. Use numeric constants or guard
if (ws.readyState === 1) // WebSocket.OPEN = 1
// OR
if (browser && ws.readyState === WebSocket.OPEN)

// 4. Initialize in onMount
onMount(() => {
  initialize();
});

References

Official Documentation

Our Codebase Examples

Web APIs

Appendix: Complete Code Changes

A. +page.svelte (Queue Dashboard)

Before:

<script lang="ts">
  import { page } from '$app/stores';
  import { onMount, onDestroy } from 'svelte';
  
  // ... state declarations
  
  onMount(async () => {
    await loadQueueItems();
    startSSEConnection();
  });
  
  function startSSEConnection() {
    try {
      eventSource = new EventSource('/api/queue/stream');
      // ...
      eventSource.addEventListener('error', (event) => {
        // ...
        setTimeout(() => {
          if (eventSource?.readyState === EventSource.CLOSED) {
            startSSEConnection();
          }
        }, 5000);
      });
    }
  }
</script>

<!-- Template -->
<div class="w-2 h-2 rounded-full {eventSource?.readyState === EventSource.OPEN ? 'bg-green-400' : 'bg-red-400'}"></div>

After:

<script lang="ts">
  import { page } from '$app/stores';
  import { browser } from '$app/environment';
  import { onMount, onDestroy } from 'svelte';
  
  // ... state declarations
  
  onMount(async () => {
    await loadQueueItems();
    if (browser) {
      startSSEConnection();
    }
  });
  
  function startSSEConnection() {
    if (!browser) return;
    
    try {
      eventSource = new EventSource('/api/queue/stream');
      // ...
      eventSource.addEventListener('error', (event) => {
        // ...
        setTimeout(() => {
          if (eventSource?.readyState === 2) { // CLOSED = 2
            startSSEConnection();
          }
        }, 5000);
      });
    }
  }
</script>

<!-- Template -->
<div class="w-2 h-2 rounded-full {browser && eventSource?.readyState === 1 ? 'bg-green-400' : 'bg-red-400'}"></div>

B. LlmHealthIndicator.svelte

Before:

<script lang="ts">
  // ...
  
  $effect(() => {
    checkHealth();
    const interval = setInterval(checkHealth, pollInterval);
    return () => clearInterval(interval);
  });
</script>

After (Option 1 - Guard $effect):

<script lang="ts">
  import { browser } from '$app/environment';
  
  // ...
  
  $effect(() => {
    if (!browser) return;
    
    checkHealth();
    const interval = setInterval(checkHealth, pollInterval);
    return () => clearInterval(interval);
  });
</script>

After (Option 2 - Use onMount - RECOMMENDED):

<script lang="ts">
  import { onMount } from 'svelte';
  
  // ...
  
  onMount(() => {
    checkHealth();
    const interval = setInterval(checkHealth, pollInterval);
    return () => clearInterval(interval);
  });
</script>

C. share/+page.svelte

Before:

<script lang="ts">
  // ...
  let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
  
  $effect(() => {
    if (targetUrl && status === 'idle') {
      process();
    }
  });
</script>

After:

<script lang="ts">
  import { onMount } from 'svelte';
  
  // ...
  let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
  let hasAutoProcessed = $state(false);
  
  onMount(() => {
    if (targetUrl && status === 'idle' && !hasAutoProcessed) {
      hasAutoProcessed = true;
      process();
    }
  });
</script>

Plan Complete - Ready for Implementation