// frontend/src/lib/sseSingleton.ts type Handler = (data: T) => void type Stream = { url: string es: EventSource | null refs: number listeners: Map> dispatchers: Map void> } const streams = new Map() let visHookInstalled = false function ensureVisHook() { if (visHookInstalled) return visHookInstalled = true document.addEventListener('visibilitychange', () => { if (document.hidden) { // Tab hidden -> alle Streams schließen for (const s of streams.values()) { if (s.es) { s.es.close() s.es = null s.dispatchers.clear() } } } else { // Tab visible -> Streams mit refs>0 wieder öffnen for (const s of streams.values()) { if (s.refs > 0 && !s.es) openStream(s) } } }) } function openStream(s: Stream) { if (s.es) return if (document.hidden) return s.es = new EventSource(s.url) // pro eventName genau EIN dispatcher registrieren for (const [eventName, set] of s.listeners.entries()) { const dispatcher = (ev: MessageEvent) => { let data: any = null try { data = JSON.parse(String(ev.data ?? 'null')) } catch { // ignore return } for (const fn of set) fn(data) } s.dispatchers.set(eventName, dispatcher) s.es.addEventListener(eventName, dispatcher as any) } s.es.onerror = () => { // EventSource reconnectet selbst. // Bei Server-Restart kann es sinnvoll sein, kurz zu schließen -> Browser baut neu auf. // (optional) } } function closeStream(s: Stream) { if (!s.es) return s.es.close() s.es = null s.dispatchers.clear() } function getStream(url: string): Stream { let s = streams.get(url) if (!s) { s = { url, es: null, refs: 0, listeners: new Map(), dispatchers: new Map(), } streams.set(url, s) } return s } /** * Subscribe auf SSE JSON Events. * - öffnet pro URL genau eine EventSource (pro Tab) * - schließt automatisch, wenn kein Subscriber mehr da ist * - pausiert in hidden Tabs */ export function subscribeSSE(url: string, eventName: string, handler: Handler) { ensureVisHook() const s = getStream(url) let set = s.listeners.get(eventName) if (!set) { set = new Set() s.listeners.set(eventName, set) } set.add(handler as Handler) s.refs += 1 // wenn Stream schon offen: ggf. Listener an EventSource nachrüsten if (s.es) { if (!s.dispatchers.has(eventName)) { const dispatcher = (ev: MessageEvent) => { let data: any = null try { data = JSON.parse(String(ev.data ?? 'null')) } catch { return } for (const fn of s.listeners.get(eventName) ?? []) fn(data) } s.dispatchers.set(eventName, dispatcher) s.es.addEventListener(eventName, dispatcher as any) } } else { openStream(s) } return () => { const s2 = streams.get(url) if (!s2) return const set2 = s2.listeners.get(eventName) if (set2) { set2.delete(handler as Handler) if (set2.size === 0) s2.listeners.delete(eventName) } s2.refs = Math.max(0, s2.refs - 1) if (s2.refs === 0) { closeStream(s2) streams.delete(url) } } }