diff --git a/package-lock.json b/package-lock.json index d02f667..06124ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "chat_app_frontend", "version": "0.1.0", "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -1523,6 +1524,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", diff --git a/package.json b/package.json index 45e4d37..5dd3398 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 374035d..d7c99b7 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -180,11 +180,11 @@ export default function ChatPage() { // ─── SSE: real-time group invite / removal ─────────────────────────────── useEffect(() => { - if (!user?.userId) return; + if (!user?.token) return; - console.log("[SSE HOOK] Initializing for user:", user.userId); + console.log("[SSE HOOK] Initializing authenticated SSE for user:", user.username); - const cleanup = subscribeToSSE(user.userId, { + const cleanup = subscribeToSSE(user.token, { // Someone added this user to a group conversation onGroupInvite: ({ conversation }) => { console.log("[SSE HOOK] onGroupInvite TRIGGERED"); @@ -291,7 +291,7 @@ useEffect(() => { }); return cleanup; -}, [user?.userId, joinRoom, leaveRoom, setCurrentRoom, setMessages]); +}, [user?.token, joinRoom, leaveRoom, setCurrentRoom, setMessages]); // React to P2P Ready event diff --git a/src/lib/api.ts b/src/lib/api.ts index 136ab65..1d8d2bc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,6 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source'; + + export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; export enum ConversationType { @@ -83,64 +86,98 @@ export async function fetchMessages(convId: string): Promise { return res.json(); } + + + export function subscribeToSSE( - userId: string, + token: string, handlers: { onGroupInvite?: (data: { conversation: any }) => void; onGroupRemoved?: (data: { conversation: any }) => void; } ): () => void { - const url = `${API_BASE_URL}/conversations/stream?userId=${userId}`; - console.log("[SSE] Opening EventSource:", url); + const url = `${API_BASE_URL}/conversations/stream`; + console.log("[SSE] Opening authenticated stream:", url); - const es = new EventSource(url); + const ctrl = new AbortController(); - es.onopen = () => { - console.log("[SSE] Connection OPENED. readyState:", es.readyState); - }; + fetchEventSource(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + signal: ctrl.signal, - // Catch-all: any unnamed event (i.e. events without `event:` field in the stream) - es.onmessage = (e: MessageEvent) => { - console.warn("[SSE] Received UNNAMED message (no event type). This means the backend is NOT setting the event name correctly."); - console.warn("[SSE] Unnamed message data:", e.data); - }; + // --- connection opened --- + onopen: async (response) => { + if (response.ok) { + console.log("[SSE] Connection OPENED. Status:", response.status); + return; // everything is fine + } - es.addEventListener("groupInvite", (e: MessageEvent) => { - console.log("[SSE] Received named event: groupInvite"); - console.log("[SSE] groupInvite raw data:", e.data); - try { - const parsed = JSON.parse(e.data); - console.log("[SSE] groupInvite parsed:", parsed); - handlers.onGroupInvite?.(parsed); - } catch (err) { - console.error("[SSE] Failed to parse groupInvite data:", err); - } + // non-retriable errors (401, 403, etc.) + const text = await response.text().catch(() => ''); + console.error(`[SSE] Server responded with ${response.status}: ${text}`); + throw new Error(`SSE connection rejected: ${response.status}`); + }, + + // --- incoming event --- + onmessage: (ev) => { + const { event, data } = ev; + + // Unnamed / heartbeat messages + if (!event || event === 'message') { + console.log("[SSE] heartbeat / unnamed message:", data); + return; + } + + console.log(`[SSE] Received event: ${event}`); + console.log(`[SSE] Raw data:`, data); + + try { + const parsed = JSON.parse(data); + + switch (event) { + case 'groupInvite': + console.log("[SSE] groupInvite parsed:", parsed); + handlers.onGroupInvite?.(parsed); + break; + + case 'groupRemoved': + console.log("[SSE] groupRemoved parsed:", parsed); + handlers.onGroupRemoved?.(parsed); + break; + + default: + console.warn("[SSE] Unhandled event type:", event, parsed); + } + } catch (err) { + console.error(`[SSE] Failed to parse ${event} data:`, err); + } + }, + + // --- error handling / reconnection --- + onerror: (err) => { + console.warn("[SSE] Connection error:", err); + + // If we intentionally aborted, don't retry + if (ctrl.signal.aborted) { + console.log("[SSE] Aborted – no retry."); + throw err; // throwing inside onerror stops reconnection + } + + // For any other error, fetchEventSource will automatically retry. + // Returning nothing (undefined) lets the library reconnect. + console.log("[SSE] Will auto-retry..."); + }, + + // Keep the connection alive — don't close on page-hide (tab switch, etc.) + openWhenHidden: true, }); - es.addEventListener("groupRemoved", (e: MessageEvent) => { - console.log("[SSE] Received named event: groupRemoved"); - console.log("[SSE] groupRemoved raw data:", e.data); - try { - const parsed = JSON.parse(e.data); - console.log("[SSE] groupRemoved parsed:", parsed); - handlers.onGroupRemoved?.(parsed); - } catch (err) { - console.error("[SSE] Failed to parse groupRemoved data:", err); - } - }); - - es.onerror = (err) => { - console.warn("[SSE] Connection error. readyState:", es.readyState, "| 0=CONNECTING, 1=OPEN, 2=CLOSED"); - console.warn("[SSE] Error object:", err); - if (es.readyState === EventSource.CLOSED) { - console.error("[SSE] Connection CLOSED permanently. Server may have rejected the request."); - } else { - console.log("[SSE] Will auto-retry (readyState=CONNECTING)..."); - } - }; - + // Return cleanup: abort the stream return () => { - console.log("[SSE] Closing EventSource for userId:", userId); - es.close(); + console.log("[SSE] Aborting SSE stream."); + ctrl.abort(); }; } \ No newline at end of file