1// ssexi.js, github.com/bigskysoftware/ssexi (Zero-Clause BSD)2(()=>{3 let send = (elt, type, detail)=>elt.dispatchEvent(new CustomEvent("fx:sse:" + type, {detail, cancelable:true, bubbles:true, composed:true}))4 async function* parseSSE(reader) {5 let buf = '', dec = new TextDecoder(), msg = {data:'', event:'', id:'', retry:null}6 while (true) {7 let {done, value} = await reader.read()8 if (done) break9 buf += dec.decode(value, {stream:true})10 let lines = buf.split(/\r\n|\r|\n/)11 buf = lines.pop()12 for (let line of lines) {13 if (!line) {14 if (msg.data || msg.event || msg.id || msg.retry) yield msg15 msg = {data:'', event:'', id:'', retry:null}16 } else if (line[0] !== ':') {17 let i = line.indexOf(':'), field = i > 0 ? line.slice(0, i) : line, val = i > 0 ? line.slice(i+1).replace(/^ /, '') : ''18 if (field === 'data') msg.data += (msg.data ? '\n' : '') + val19 else if (field === 'retry') msg.retry = parseInt(val) || null20 else if (field in msg) msg[field] = val21 }22 }23 }24 if (msg.data || msg.event || msg.id || msg.retry) yield msg25 }26 document.addEventListener("fx:config", (evt)=>{27 let cfg = evt.detail.cfg, realFetch = cfg.fetch28 cfg.headers.Accept ??= "text/html, text/event-stream"29 cfg.fetch = async(url, options)=>{30 let response = await realFetch(url, options)31 if (!response.headers.get('Content-Type')?.includes('text/event-stream')) return response32 let style = cfg.sseSwap || cfg.swap, sse = cfg.sse = {lastEventId:'', retry:null, reader:response.body.getReader(), closed:false}33 sse.close = ()=>{ sse.closed = true; sse.reader?.cancel() }34 if (!send(cfg.target, 'open', {cfg, response})) { cfg.swap = 'none'; return new Response('') }35 let target = cfg.target, anchor = null36 if (style === 'outerHTML') { anchor = document.createElement('i'); anchor.style.display = 'none'; target.after(anchor) }37 let et = anchor || target38 let onVis = null39 if (cfg.sseDisconnectOnHidden) onVis = ()=>document.hidden && sse.close()40 else if (cfg.ssePauseOnHidden) onVis = ()=>document.hidden && sse.reader?.cancel()41 if (onVis) document.addEventListener('visibilitychange', onVis)42 try {43 while (!sse.closed) {44 try {45 for await (let msg of parseSSE(sse.reader)) {46 if (msg.id) sse.lastEventId = msg.id47 if (msg.retry) sse.retry = msg.retry48 if (!send(et, 'message', {cfg, message:msg})) { sse.close(); break }49 let t = target, s = style, tx = false50 if (msg.event && msg.event[0] === '{') {51 let e = JSON.parse(msg.event)52 if (e.target) t = document.querySelector(e.target)53 if (e.swap) s = e.swap54 tx = e.transition55 } else if (msg.event) { send(et, msg.event, {cfg, message:msg}); t = null }56 if (t && msg.data) {57 let swap = ()=>{58 if (s instanceof Function) s(t, msg.data)59 else if (/(before|after)(begin|end)/i.test(s)) t.insertAdjacentHTML(s, msg.data)60 else if (s in t) t[s] = msg.data61 }62 if (tx && cfg.transition) await cfg.transition(swap).finished63 else swap()64 send(t.isConnected ? t : et, 'swapped', {cfg, message:msg})65 if (t === target && anchor) { target = anchor; style = 'beforebegin' }66 }67 }68 send(et, 'close', {cfg})69 } catch(error) { send(et, 'error', {cfg, error}) }70 if (sse.closed) break71 if (!cfg.sseReconnect && !(cfg.ssePauseOnHidden && document.hidden)) break72 if (cfg.ssePauseOnHidden && document.hidden) {73 await new Promise(r=>{let h=()=>{if(!document.hidden||sse.closed){document.removeEventListener('visibilitychange',h);r()}};document.addEventListener('visibilitychange',h)})74 } else await new Promise(r=>setTimeout(r, sse.retry || 3000))75 if (sse.closed) break76 try {77 let hdrs = new Headers(options.headers)78 if (sse.lastEventId) hdrs.set('Last-Event-ID', sse.lastEventId)79 sse.reader = (await realFetch(url, {...options, headers: hdrs})).body.getReader()80 } catch(error) { send(et, 'error', {cfg, error}); continue }81 }82 } finally {83 anchor?.remove()84 if (onVis) document.removeEventListener('visibilitychange', onVis)85 }86 cfg.swap = 'none'87 return new Response('')88 }89 })90})()