From a6336e293c2d1638dcf5a746b7abae844020695b Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Thu, 19 Jun 2025 23:48:18 -0600 Subject: [PATCH] SSE Fixes --- src/components/SpotifyIcon.tsx | 114 +++++++++++++++++++++++--------- src/pages/api/spotify/stream.ts | 45 ++++++++++--- 2 files changed, 115 insertions(+), 44 deletions(-) diff --git a/src/components/SpotifyIcon.tsx b/src/components/SpotifyIcon.tsx index 414cfef..562b6a7 100644 --- a/src/components/SpotifyIcon.tsx +++ b/src/components/SpotifyIcon.tsx @@ -21,18 +21,20 @@ interface SpotifyIconProps { // Spotify SVG icon component const SpotifySVG = ({ className }: { className?: string }) => ( - - + ); -export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }: SpotifyIconProps) { +export default function SpotifyIcon({ + profileUrl = "https://open.spotify.com", +}: SpotifyIconProps) { const currentTrack = useSignal(null); const isPlaying = useSignal(false); const isDynamicEnabled = useSignal(false); @@ -42,15 +44,19 @@ export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }: // First, check if Spotify is properly configured const checkConfiguration = async () => { try { - const response = await fetch('/api/spotify/config'); + const response = await fetch("/api/spotify/config"); if (!response.ok) { - throw new Error(`Spotify config endpoint returned status ${response.status}`); + throw new Error( + `Spotify config endpoint returned status ${response.status}`, + ); } const { configured } = await response.json(); - + if (!configured) { if (!hasLoggedError.value) { - console.warn('[Spotify] Dynamic features disabled: missing or invalid environment variables.'); + console.warn( + "[Spotify] Dynamic features disabled: missing or invalid environment variables.", + ); hasLoggedError.value = true; } isDynamicEnabled.value = false; @@ -60,7 +66,10 @@ export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }: initializeSpotifyConnection(); } catch (error: any) { if (!hasLoggedError.value) { - console.error('[Spotify] Could not enable dynamic features:', error?.message || error); + console.error( + "[Spotify] Could not enable dynamic features:", + error?.message || error, + ); hasLoggedError.value = true; } isDynamicEnabled.value = false; @@ -72,42 +81,71 @@ export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }: const initializeSpotifyConnection = () => { // Set up Server-Sent Events connection - const eventSource = new EventSource('/api/spotify/stream'); - + const eventSource = new EventSource("/api/spotify/stream"); + eventSource.onopen = () => { // Only log on first open if (!hasLoggedError.value) { - console.info('[Spotify] SSE connected for dynamic icon.'); + console.info("[Spotify] SSE connected for dynamic icon."); } }; - + eventSource.onmessage = (event) => { try { - const data: SpotifyResponse = JSON.parse(event.data); - if (data.is_playing && data.item) { - currentTrack.value = data.item; - isPlaying.value = data.is_playing; - } else { - currentTrack.value = null; - isPlaying.value = false; + const message = JSON.parse(event.data); + + // Handle different message types + if (message.type === "keepalive") { + // Just a keepalive, no need to do anything + return; + } + + if (message.type === "track" && message.data) { + const data: SpotifyResponse = message.data; + if (data.is_playing && data.item) { + currentTrack.value = data.item; + isPlaying.value = data.is_playing; + } else { + currentTrack.value = null; + isPlaying.value = false; + } } } catch (err) { if (!hasLoggedError.value) { - console.error('[Spotify] Error parsing SSE data:', err); + console.error( + "[Spotify] Error parsing SSE data:", + err, + "Raw data:", + event.data, + ); hasLoggedError.value = true; } // Fail silently - will revert to static mode } }; - + eventSource.onerror = (event) => { if (!hasLoggedError.value) { - console.error('[Spotify] SSE connection error. Falling back to static icon.'); + console.error( + "[Spotify] SSE connection error. Falling back to static icon.", + { + readyState: eventSource.readyState, + url: eventSource.url, + event: event, + }, + ); hasLoggedError.value = true; } - // If SSE fails, we just fall back to static mode + + // Close the connection to prevent retries + eventSource.close(); + + // Set to static mode + isDynamicEnabled.value = false; + currentTrack.value = null; + isPlaying.value = false; }; - + // Cleanup on unmount return () => { eventSource.close(); @@ -116,7 +154,9 @@ export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }: const getTooltipText = () => { if (isDynamicEnabled.value && currentTrack.value && isPlaying.value) { - const artists = currentTrack.value.artists.map(artist => artist.name).join(', '); + const artists = currentTrack.value.artists + .map((artist) => artist.name) + .join(", "); return `♪ ${currentTrack.value.name} by ${artists} (click to open in Spotify)`; } return "Spotify"; @@ -124,7 +164,12 @@ export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }: const getSpotifyUrl = () => { // If we have a current track with external URL, use that - if (isDynamicEnabled.value && currentTrack.value && isPlaying.value && currentTrack.value.external_urls?.spotify) { + if ( + isDynamicEnabled.value && + currentTrack.value && + isPlaying.value && + currentTrack.value.external_urls?.spotify + ) { return currentTrack.value.external_urls.spotify; } // Otherwise, use the provided profile URL or fallback to general Spotify @@ -140,12 +185,15 @@ export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }: aria-label="Spotify" className="hover:text-primary transition-colors relative inline-block" > -
@@ -156,4 +204,4 @@ export default function SpotifyIcon({ profileUrl = "https://open.spotify.com" }:
); -} \ No newline at end of file +} diff --git a/src/pages/api/spotify/stream.ts b/src/pages/api/spotify/stream.ts index 31379d0..d0eb5a5 100644 --- a/src/pages/api/spotify/stream.ts +++ b/src/pages/api/spotify/stream.ts @@ -132,15 +132,15 @@ export const GET: APIRoute = async ({ request }) => { const stream = new ReadableStream({ start(ctrl) { controller = ctrl; + // Send initial data immediately to establish connection + const message = `data: ${JSON.stringify({ type: "connected", timestamp: Date.now() })}\n\n`; + controller.enqueue(encoder.encode(message)); }, cancel() { // Client disconnected console.log("SSE stream cancelled by client"); isClosed = true; - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } + cleanup(); }, }); @@ -196,7 +196,11 @@ export const GET: APIRoute = async ({ request }) => { JSON.stringify(currentTrack) !== JSON.stringify(lastTrackData) ) { lastTrackData = currentTrack; - sendMessage(currentTrack || { is_playing: false, item: null }); + sendMessage({ + type: "track", + data: currentTrack || { is_playing: false, item: null }, + timestamp: Date.now(), + }); } } catch (error) { if (!isClosed) { @@ -205,29 +209,48 @@ export const GET: APIRoute = async ({ request }) => { } }; - // Send initial data + // Send initial data immediately poll(); // Poll every 3 seconds pollInterval = setInterval(poll, 3000); - // Clean up when client disconnects (abort signal) - request.signal.addEventListener("abort", () => { - console.log("SSE request aborted"); - isClosed = true; + // Send keepalive every 30 seconds to prevent connection timeout + const keepaliveInterval = setInterval(() => { + if (!isClosed) { + sendMessage({ type: "keepalive", timestamp: Date.now() }); + } + }, 30000); + + // Clean up keepalive interval too + const cleanup = () => { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + if (keepaliveInterval) { + clearInterval(keepaliveInterval); + } + }; + + // Clean up when client disconnects (abort signal) + request.signal.addEventListener("abort", () => { + console.log("SSE request aborted"); + isClosed = true; + cleanup(); }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-store, must-revalidate", Connection: "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Cache-Control", + "X-Accel-Buffering": "no", + "CF-Cache-Status": "BYPASS", + "Transfer-Encoding": "chunked", + Vary: "Accept-Encoding", }, }); } catch (error) {