@@ -24,88 +24,165 @@ function sseGlobals(): SSEDebugGlobals {
2424 return window as unknown as SSEDebugGlobals ;
2525}
2626
27- /**
28- * Composable for connecting to the unified SSE event stream.
29- *
30- * The browser's EventSource handles reconnection automatically and
31- * sends the Last-Event-ID header so the server can catch up on missed events.
32- *
33- * @param onEvent - callback invoked for every SSE event
34- * @param eventTypes - subset of event types to listen to (defaults to all)
35- */
36- export function useSSE ( onEvent : ( event : MessageEvent ) => void , eventTypes : readonly SSEEventType [ ] = SSE_EVENT_TYPES ) {
37- const connected = ref ( false ) ;
38- let eventSource : EventSource | null = null ;
39-
40- // Selenium tests watch __galaxy_sse_last_event_ts to prove that an
41- // observable state change came from an SSE push and not the polling
42- // fallback (where __galaxy_sse_last_event_ts would never advance).
43- const trackedOnEvent = ( event : MessageEvent ) => {
44- sseGlobals ( ) . __galaxy_sse_last_event_ts = Date . now ( ) ;
45- onEvent ( event ) ;
27+ // ---------------------------------------------------------------------------
28+ // Module-level shared EventSource.
29+ //
30+ // Every call to ``useSSE`` registers its handler against this one socket so
31+ // the tab opens a single ``/api/events/stream`` connection no matter how many
32+ // stores listen. HTTP/1.1 caps simultaneous connections per origin at six;
33+ // before this consolidation we burned three slots on SSE alone (history,
34+ // notifications, entry points), which is what starved the scratchbook iframe
35+ // flow — see the fix in ``client/src/entry/analysis/App.vue``.
36+ // ---------------------------------------------------------------------------
37+
38+ type Handler = ( event : MessageEvent ) => void ;
39+
40+ let sharedSource : EventSource | null = null ;
41+ const sharedConnected = ref ( false ) ;
42+ const subscribers : Map < SSEEventType , Set < Handler > > = new Map ( ) ;
43+ // Track the per-type dispatchers we registered so ``closeSource`` removes the
44+ // exact same listeners (``addEventListener`` matches by reference).
45+ const dispatchers : Map < SSEEventType , Handler > = new Map ( ) ;
46+
47+ function openSourceIfNeeded ( ) {
48+ if ( sharedSource ) {
49+ return ;
50+ }
51+ sharedSource = new EventSource ( withPrefix ( "/api/events/stream" ) ) ;
52+
53+ for ( const eventType of SSE_EVENT_TYPES ) {
54+ const dispatcher : Handler = ( event ) => {
55+ // Selenium tests watch ``__galaxy_sse_last_event_ts`` to prove that
56+ // an observable state change came from an SSE push and not the
57+ // polling fallback (where the global would never advance).
58+ sseGlobals ( ) . __galaxy_sse_last_event_ts = Date . now ( ) ;
59+ const subs = subscribers . get ( eventType ) ;
60+ if ( ! subs ) {
61+ return ;
62+ }
63+ for ( const handler of subs ) {
64+ handler ( event ) ;
65+ }
66+ } ;
67+ dispatchers . set ( eventType , dispatcher ) ;
68+ sharedSource . addEventListener ( eventType , dispatcher ) ;
69+ }
70+
71+ sharedSource . onopen = ( ) => {
72+ sharedConnected . value = true ;
73+ // Global readiness flag so Selenium tests can distinguish a working
74+ // SSE pipeline from the polling fallback.
75+ sseGlobals ( ) . __galaxy_sse_connected = true ;
76+ } ;
77+
78+ sharedSource . onerror = ( ) => {
79+ // EventSource auto-reconnects natively; SSE-vs-polling is a
80+ // config-level decision (see historyStore / notificationsStore), so
81+ // we must not give up on transient errors here — doing so would leave
82+ // the client with no updates at all.
83+ sharedConnected . value = false ;
84+ sseGlobals ( ) . __galaxy_sse_connected = false ;
4685 } ;
4786
4887 // Browser EventSource teardown during a full-page navigation
4988 // (``window.location.href = …``) is not guaranteed to happen before the
5089 // browser issues requests for the new page — we've seen Chrome keep the
5190 // stream alive long enough that a login/register POST reload races the
5291 // close, and the new page then loads with a stale auth view. Force a
53- // synchronous ``eventSource.close()`` during ``pagehide`` (fires for both
54- // reloads and tab-close, unlike ``beforeunload``) to close that window.
55- // The listener is registered only while a connection is live so composables
56- // that never ``connect()`` don't leave dangling listeners behind.
57- const onPageHide = ( ) => disconnect ( ) ;
92+ // synchronous ``close()`` during ``pagehide`` (fires for both reloads and
93+ // tab-close, unlike ``beforeunload``) to close that window.
94+ if ( typeof window !== "undefined" ) {
95+ window . addEventListener ( "pagehide" , closeSource ) ;
96+ }
97+ }
5898
59- function connect ( ) {
60- disconnect ( ) ;
61- const url = withPrefix ( "/api/events/stream" ) ;
62- eventSource = new EventSource ( url ) ;
99+ function closeSource ( ) {
100+ if ( ! sharedSource ) {
101+ return ;
102+ }
103+ for ( const [ eventType , dispatcher ] of dispatchers ) {
104+ sharedSource . removeEventListener ( eventType , dispatcher ) ;
105+ }
106+ dispatchers . clear ( ) ;
107+ sharedSource . close ( ) ;
108+ sharedSource = null ;
109+ sharedConnected . value = false ;
110+ sseGlobals ( ) . __galaxy_sse_connected = false ;
111+ if ( typeof window !== "undefined" ) {
112+ window . removeEventListener ( "pagehide" , closeSource ) ;
113+ }
114+ }
63115
64- for ( const eventType of eventTypes ) {
65- eventSource . addEventListener ( eventType , trackedOnEvent ) ;
116+ function addSubscriber ( onEvent : Handler , eventTypes : readonly SSEEventType [ ] ) {
117+ for ( const eventType of eventTypes ) {
118+ let subs = subscribers . get ( eventType ) ;
119+ if ( ! subs ) {
120+ subs = new Set ( ) ;
121+ subscribers . set ( eventType , subs ) ;
66122 }
123+ subs . add ( onEvent ) ;
124+ }
125+ }
67126
68- eventSource . onopen = ( ) => {
69- connected . value = true ;
70- // Expose a global readiness flag so Selenium tests can distinguish
71- // a working SSE pipeline from the polling fallback.
72- sseGlobals ( ) . __galaxy_sse_connected = true ;
73- } ;
127+ function removeSubscriber ( onEvent : Handler , eventTypes : readonly SSEEventType [ ] ) : boolean {
128+ let anyRemaining = false ;
129+ for ( const eventType of eventTypes ) {
130+ const subs = subscribers . get ( eventType ) ;
131+ if ( subs ) {
132+ subs . delete ( onEvent ) ;
133+ if ( subs . size === 0 ) {
134+ subscribers . delete ( eventType ) ;
135+ }
136+ }
137+ }
138+ for ( const subs of subscribers . values ( ) ) {
139+ if ( subs . size > 0 ) {
140+ anyRemaining = true ;
141+ break ;
142+ }
143+ }
144+ return anyRemaining ;
145+ }
74146
75- eventSource . onerror = ( ) => {
76- // EventSource auto-reconnects natively; SSE-vs-polling is a
77- // config-level decision (see historyStore / notificationsStore),
78- // so we must not give up on transient errors here — doing so
79- // would leave the client with no updates at all.
80- connected . value = false ;
81- sseGlobals ( ) . __galaxy_sse_connected = false ;
82- } ;
147+ /**
148+ * Composable for subscribing to events on the shared SSE stream.
149+ *
150+ * The browser's EventSource handles reconnection automatically and sends the
151+ * ``Last-Event-ID`` header so the server can catch up on missed events. Only
152+ * one EventSource is opened per tab regardless of how many callers invoke
153+ * this composable; the composable multiplexes dispatch per event type.
154+ *
155+ * @param onEvent - callback invoked for every matching SSE event
156+ * @param eventTypes - subset of event types to listen to (defaults to all)
157+ */
158+ export function useSSE ( onEvent : Handler , eventTypes : readonly SSEEventType [ ] = SSE_EVENT_TYPES ) {
159+ let connected_ : boolean = false ;
83160
84- if ( typeof window !== "undefined" ) {
85- window . addEventListener ( "pagehide" , onPageHide ) ;
161+ function connect ( ) {
162+ if ( connected_ ) {
163+ return ;
86164 }
165+ connected_ = true ;
166+ addSubscriber ( onEvent , eventTypes ) ;
167+ openSourceIfNeeded ( ) ;
87168 }
88169
89170 function disconnect ( ) {
90- if ( eventSource ) {
91- for ( const eventType of eventTypes ) {
92- eventSource . removeEventListener ( eventType , trackedOnEvent ) ;
93- }
94- eventSource . close ( ) ;
95- eventSource = null ;
171+ if ( ! connected_ ) {
172+ return ;
96173 }
97- if ( typeof window !== "undefined" ) {
98- window . removeEventListener ( "pagehide" , onPageHide ) ;
174+ connected_ = false ;
175+ const anyRemaining = removeSubscriber ( onEvent , eventTypes ) ;
176+ if ( ! anyRemaining ) {
177+ closeSource ( ) ;
99178 }
100- connected . value = false ;
101- sseGlobals ( ) . __galaxy_sse_connected = false ;
102179 }
103180
104181 onScopeDispose ( ( ) => {
105182 disconnect ( ) ;
106183 } ) ;
107184
108- return { connect, disconnect, connected } ;
185+ return { connect, disconnect, connected : sharedConnected } ;
109186}
110187
111188/**
0 commit comments