- 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
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
- src/routes/+page.svelte - EventSource accessed at L299, L82 without browser guards
- src/routes/share/+page.svelte -
$effectwith side effects (callsprocess()function) - src/routes/share/components/LlmHealthIndicator.svelte -
$effectwithsetInterval(no browser guard)
Affected Files - Already Compliant (Good Examples)
-
src/lib/client/PushNotificationManager.ts ✅
- Properly uses
browserfrom$app/environment(L10) - Guards
localStorageaccess (L296, L300) - Guards
window.atobaccess (L318) - Guards
navigator.serviceWorkeraccess (L111)
- Properly uses
-
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,sessionStoragenavigator.*EventSource,WebSocketlocation.*
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 patternsServiceWorkerMessageHandler.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):
-
+page.svelte (Queue Dashboard)
- L299:
eventSource?.readyState === EventSource.OPEN- No browser guard - L82:
eventSource?.readyState === EventSource.CLOSED- No browser guard - L67:
new EventSource()- InsideonMountbut needs explicit guard - Missing
browserimport
- L299:
-
LlmHealthIndicator.svelte
- L36-39:
$effectwithsetInterval- No browser guard - Should use
onMountinstead for timer setup
- L36-39:
Medium Priority (Anti-patterns): 3. share/+page.svelte
- L22-25:
$effectcallingprocess()with side effects - Should use
onMountwith conditional logic instead $effectis meant for synchronization, not side effects
📋 Not Issues (Clarifications)
setTimeoutin components (L81 in +page.svelte, L53 in share/+page.svelte) - ✅ OK because insideonMountor event handlersgotofrom$app/navigation- ✅ SSR-safe (SvelteKit handles this)$pagestore 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
browserfrom$app/environment - Guard
EventSourceconstructor instartSSEConnection() - Replace
EventSource.OPENconstant with numeric value1or add browser guard - Replace
EventSource.CLOSEDconstant with numeric value2or 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.CLOSEDto2or guard withbrowser - L299: Change
EventSource.OPENto1or guard withbrowser
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
$effectwithonMountfor 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
$effectfor synchronizing derived state, DOM manipulation, or reactive cleanup. UseonMountfor 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
$effectcontainingsetInterval - 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:
- Browser API detection with
$app/environment - Lifecycle hook usage (
onMountvs$effect) - Common gotchas (static constants, timers, storage APIs)
- 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 onMountcallbacks - run client-side
Implementation Plan
Phase 1: Critical Fixes (Blocks Production)
Priority: URGENT - Fixes SSR crashes
-
Story 1 - Fix EventSource in Queue Dashboard
- Add
browserimport - Guard EventSource creation
- Fix static constant references
- Test SSR rendering
- Add
-
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
- 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
-
Story 5 - Comprehensive SSR Audit
- Run production build
- Test all routes
- Verify no SSR errors
-
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 = 0EventSource.OPEN = 1EventSource.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):
src/routes/+page.svelte- Queue dashboardsrc/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
- Mitigation: Use numeric constants; avoid conditional rendering based on
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:
-
SSR Testing:
npm run build npm run preview # Check server console for errors # Navigate to all pages -
EventSource Connection:
- Open queue dashboard
- Check browser DevTools → Network → EventSource
- Verify "Live updates" status indicator
- Add queue item and verify real-time update
-
Share Page:
- Navigate to
/share - Manually enter URL → should work
- Share from Instagram → should auto-process
- Check no duplicate processing
- Navigate to
-
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)
- ✅ No
EventSource is not definederrors - ✅ No
setInterval is not definederrors - ✅ Production build succeeds
- ✅ SSR renders without errors
- ✅ Live updates work in browser
Should Have (Phase 2)
- ✅ No
$effectanti-patterns - ✅ No hydration warnings
- ✅ Share page auto-processing works
Nice to Have (Phase 3)
- ✅ SSR best practices documentation
- ✅ Inline comments explaining patterns
- ✅ 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
- SvelteKit SSR - From llms-full.txt
- Svelte Runes - $state, $derived, $effect
- SvelteKit $app modules - $app/environment, $app/stores
Our Codebase Examples
- Good: PushNotificationManager.ts - Excellent SSR-safe patterns
- Good: ServiceWorkerMessageHandler.ts - Client-only scope
- Fix: +page.svelte - EventSource needs guards
- Fix: LlmHealthIndicator.svelte - setInterval needs guard
- Improve: share/+page.svelte - $effect anti-pattern
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