1- import { useEffect , useState , useCallback , useRef } from 'react' ;
1+ import { useEffect , useState , useCallback , useRef , useMemo } from 'react' ;
22
33interface StreamEvent {
44 type : 'created' | 'topped_up' | 'withdrawn' | 'cancelled' | 'completed' | 'paused' | 'resumed' ;
@@ -23,12 +23,13 @@ interface UseStreamEventsReturn {
2323 clearEvents : ( ) => void ;
2424}
2525
26+ const MAX_RECONNECT_ATTEMPTS = 20 ;
27+
2628export function useStreamEvents (
2729 options : UseStreamEventsOptions = { }
2830) : UseStreamEventsReturn {
2931 const {
30- streamIds = [ ] ,
31- // userPublicKeys = [],
32+ streamIds : rawStreamIds = [ ] ,
3233 subscribeToAll = false ,
3334 autoReconnect = true ,
3435 maxRetryDelay = 30000 ,
@@ -43,32 +44,43 @@ export function useStreamEvents(
4344 const eventSourceRef = useRef < EventSource | null > ( null ) ;
4445 const retryDelayRef = useRef ( 1000 ) ;
4546 const reconnectTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
47+ const reconnectAttemptsRef = useRef ( 0 ) ;
4648 const connectRef = useRef < ( ) => void > ( ( ) => undefined ) ;
4749
50+ const subscriptionKey = useMemo ( ( ) => {
51+ const streams = [ ...rawStreamIds ] . sort ( ) . join ( ',' ) ;
52+ return `${ subscribeToAll ? 'all' : streams } |${ jwtToken || '' } ` ;
53+ } , [ rawStreamIds , subscribeToAll , jwtToken ] ) ;
54+
4855 const buildUrl = useCallback ( ( ) => {
4956 const params = new URLSearchParams ( ) ;
5057
5158 if ( subscribeToAll ) {
5259 params . append ( 'all' , 'true' ) ;
5360 } else {
54- streamIds . forEach ( id => params . append ( 'streams' , id ) ) ;
61+ rawStreamIds . forEach ( id => params . append ( 'streams' , id ) ) ;
5562 }
5663
57- // Add JWT token to query string for authentication
58- // (EventSource doesn't support custom headers in browser)
5964 if ( jwtToken ) {
6065 params . append ( 'token' , jwtToken ) ;
6166 }
6267
6368 const baseUrl = process . env . NEXT_PUBLIC_API_URL || 'http://localhost:3001' ;
6469 return `${ baseUrl } /v1/events/subscribe?${ params } ` ;
65- } , [ streamIds , subscribeToAll , jwtToken ] ) ;
70+ // subscriptionKey captures all subscription parameters as a stable string
71+ // eslint-disable-next-line react-hooks/exhaustive-deps
72+ } , [ subscriptionKey ] ) ;
6673
6774 const clearEvents = useCallback ( ( ) => {
6875 setEvents ( [ ] ) ;
6976 } , [ ] ) ;
7077
7178 const connect = useCallback ( ( ) => {
79+ if ( reconnectTimeoutRef . current !== null ) {
80+ clearTimeout ( reconnectTimeoutRef . current ) ;
81+ reconnectTimeoutRef . current = null ;
82+ }
83+
7284 const url = buildUrl ( ) ;
7385 const eventSource = new EventSource ( url ) ;
7486 eventSourceRef . current = eventSource ;
@@ -77,16 +89,16 @@ export function useStreamEvents(
7789 setConnected ( true ) ;
7890 setReconnecting ( false ) ;
7991 setError ( null ) ;
80- retryDelayRef . current = 1000 ; // Reset retry delay
92+ retryDelayRef . current = 1000 ;
93+ reconnectAttemptsRef . current = 0 ;
8194 } ;
8295
83-
8496 const handleEvent = ( type : StreamEvent [ 'type' ] ) => ( e : MessageEvent ) => {
8597 try {
8698 const data = JSON . parse ( e . data ) ;
8799 setEvents ( ( prev : StreamEvent [ ] ) => [
88100 { type, data, timestamp : Date . now ( ) } ,
89- ...prev . slice ( 0 , 99 ) , // Keep last 100 events
101+ ...prev . slice ( 0 , 99 ) ,
90102 ] ) ;
91103 } catch {
92104 // Silently ignore malformed event messages
@@ -108,14 +120,24 @@ export function useStreamEvents(
108120
109121 if ( autoReconnect ) {
110122 setReconnecting ( true ) ;
111- // Cap the delay we're about to wait on, and precompute the next
112- // (doubled, capped) delay up front so consecutive failures keep
113- // growing the backoff even if the next attempt fails immediately.
114- const delay = Math . min ( retryDelayRef . current , maxRetryDelay ) ;
115- retryDelayRef . current = Math . min ( retryDelayRef . current * 2 , maxRetryDelay ) ;
116- reconnectTimeoutRef . current = setTimeout ( ( ) => {
117- connectRef . current ( ) ;
118- } , delay ) ;
123+
124+ if ( reconnectTimeoutRef . current !== null ) {
125+ clearTimeout ( reconnectTimeoutRef . current ) ;
126+ reconnectTimeoutRef . current = null ;
127+ }
128+
129+ reconnectAttemptsRef . current += 1 ;
130+
131+ if ( reconnectAttemptsRef . current <= MAX_RECONNECT_ATTEMPTS ) {
132+ // Cap the delay we're about to wait on, and precompute the next
133+ // (doubled, capped) delay up front so consecutive failures keep
134+ // growing the backoff even if the next attempt fails immediately.
135+ const delay = Math . min ( retryDelayRef . current , maxRetryDelay ) ;
136+ retryDelayRef . current = Math . min ( retryDelayRef . current * 2 , maxRetryDelay ) ;
137+ reconnectTimeoutRef . current = setTimeout ( ( ) => {
138+ connectRef . current ( ) ;
139+ } , delay ) ;
140+ }
119141 }
120142 } ;
121143 } , [ buildUrl , autoReconnect , maxRetryDelay ] ) ;
@@ -132,8 +154,9 @@ export function useStreamEvents(
132154 eventSourceRef . current . close ( ) ;
133155 eventSourceRef . current = null ;
134156 }
135- if ( reconnectTimeoutRef . current ) {
157+ if ( reconnectTimeoutRef . current !== null ) {
136158 clearTimeout ( reconnectTimeoutRef . current ) ;
159+ reconnectTimeoutRef . current = null ;
137160 }
138161 } ;
139162 } , [ connect ] ) ;
0 commit comments