145 lines
3.3 KiB
TypeScript
145 lines
3.3 KiB
TypeScript
// frontend/src/lib/sseSingleton.ts
|
|
type Handler<T = any> = (data: T) => void
|
|
|
|
type Stream = {
|
|
url: string
|
|
es: EventSource | null
|
|
refs: number
|
|
listeners: Map<string, Set<Handler>>
|
|
dispatchers: Map<string, (ev: MessageEvent) => void>
|
|
}
|
|
|
|
const streams = new Map<string, Stream>()
|
|
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<T = any>(url: string, eventName: string, handler: Handler<T>) {
|
|
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)
|
|
}
|
|
}
|
|
}
|