nsfwapp/frontend/src/lib/sseSingleton.ts
2026-01-13 14:00:05 +01:00

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)
}
}
}