From f8d772149b8e3cbdbfc773692b5c88afff56219a Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Wed, 8 Jul 2026 13:56:10 -0400 Subject: [PATCH] fix: enhance SSE connection handling for native platforms and implement ticket fetching --- src/hooks/useSSE.js | 76 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js index 8a63f20..1653b63 100644 --- a/src/hooks/useSSE.js +++ b/src/hooks/useSSE.js @@ -1,3 +1,4 @@ +import { Capacitor } from '@capacitor/core' import { useQueryClient } from '@tanstack/react-query' import { EventSourcePolyfill } from 'event-source-polyfill' import { useCallback, useEffect, useRef, useState } from 'react' @@ -60,6 +61,24 @@ export const useSSE = () => { return { url: sseUrl, token } }, [token, isAuthenticated]) // Fixed: Added missing dependencies + // Exchange the JWT (sent via Authorization header by apiClient) for a + // short-lived, single-use SSE ticket. Used by the native EventSource path, + // which cannot send custom headers. + const fetchSSETicket = useCallback(async () => { + try { + const response = await apiClient.get('/realtime/sse/ticket') + if (!response || !response.ok) { + console.error('SSE: Ticket request failed', response?.status) + return null + } + const data = await response.json() + return data.ticket || null + } catch (err) { + console.error('SSE: Ticket request error', err) + return null + } + }, []) + const handleSSEMessage = useCallback( event => { try { @@ -289,7 +308,7 @@ export const useSSE = () => { }, []) // Create connect function that can be called from anywhere - const connect = useCallback(() => { + const connect = useCallback(async () => { // Clear the scheduled flag when actually connecting isReconnectScheduledRef.current = false @@ -348,16 +367,49 @@ export const useSSE = () => { setConnectionState(SSE_STATES.CONNECTING) isManuallyClosedRef.current = false - eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, { - headers: { - Authorization: `Bearer ${localStorage.getItem('token')}`, - 'Cache-Control': 'no-cache', - Accept: 'text/event-stream', - }, - withCredentials: true, - heartbeatTimeout: 120000, - silentTimeoutRetry: true, - }) + if (Capacitor.isNativePlatform()) { + // Capacitor's native HTTP bridge does not support streaming responses, + // which breaks EventSourcePolyfill (fetch/XHR based). Use the native + // EventSource instead, which uses the WKWebView HTTP stack directly. + // Native EventSource cannot send custom headers, so we first exchange + // our JWT (sent in the Authorization header) for a short-lived, + // single-use ticket and pass that ticket as a query parameter. This + // keeps the long-lived token out of URLs and proxy access logs. + const ticket = await fetchSSETicket() + if (!ticket) { + console.error('SSE: Failed to obtain connection ticket') + setError('Connection error occurred') + setConnectionState(SSE_STATES.CLOSED) + scheduleReconnect( + RECONNECT_INTERVALS[ + Math.min( + reconnectAttemptsRef.current, + RECONNECT_INTERVALS.length - 1, + ) + ], + 'ticket-fetch-failed', + ) + return + } + + const nativeUrl = new URL(sseConfig.url) + nativeUrl.searchParams.set('ticket', ticket) + + eventSourceRef.current = new EventSource(nativeUrl.toString(), { + withCredentials: true, + }) + } else { + eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + 'Cache-Control': 'no-cache', + Accept: 'text/event-stream', + }, + withCredentials: true, + heartbeatTimeout: 120000, + silentTimeoutRetry: true, + }) + } eventSourceRef.current.onopen = () => { console.log('SSE connection opened') @@ -573,8 +625,10 @@ export const useSSE = () => { } }, [ getSSEUrl, + fetchSSETicket, handleSSEMessage, stopHeartbeatMonitor, + scheduleReconnect, isCircuitBreakerOpen, showError, ])