function streamTextToElement(element) { if (element.dataset.sseBound === "true") { return; } const url = element.dataset.sseUrl; if (!url) { return; } const source = new EventSource(url); element._sseSource = source; element.dataset.sseBound = "true"; const appendLine = (line) => { element.textContent += `${line}\n`; element.scrollTop = element.scrollHeight; }; source.onmessage = (event) => { appendLine(event.data); }; source.addEventListener("stdout", (event) => { appendLine(event.data); }); source.addEventListener("stderr", (event) => { appendLine(`[stderr] ${event.data}`); }); } function bindSseIn(root) { if (!root) return; const scope = root.matches?.("[data-sse-url]") ? [root] : []; if (root.querySelectorAll) { root.querySelectorAll("[data-sse-url]").forEach((el) => scope.push(el)); } scope.forEach(streamTextToElement); } function closeSseIn(root) { if (!root) return; const scope = root.matches?.("[data-sse-url]") ? [root] : []; if (root.querySelectorAll) { root.querySelectorAll("[data-sse-url]").forEach((el) => scope.push(el)); } scope.forEach((el) => { if (el._sseSource) { el._sseSource.close(); el._sseSource = null; delete el.dataset.sseBound; } }); } document.addEventListener("DOMContentLoaded", () => bindSseIn(document)); // HTMX fires `htmx:load` for the initial document and after every swap, so // dynamically inserted log-stream elements get bound. `htmx:beforeCleanupElement` // fires for elements about to be removed; close their EventSources first to // stop the previous stream and avoid leaking sockets. document.addEventListener("htmx:load", (event) => bindSseIn(event.detail.elt)); document.addEventListener("htmx:beforeCleanupElement", (event) => closeSseIn(event.detail.elt), );