/* Server Sent Events Extension ============================ This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. */ (function() { /** @type {import("../htmx").HtmxInternalApi} */ var api; htmx.defineExtension("sse", { /** * Init saves the provided reference to the internal HTMX API. * * @param {import("../htmx").HtmxInternalApi} api * @returns void */ init: function(apiRef) { // store a reference to the internal API. api = apiRef; // set a function in the public API for creating new EventSource objects if (htmx.createEventSource == undefined) { htmx.createEventSource = createEventSource; } }, /** * onEvent handles all events passed to this extension. * * @param {string} name * @param {Event} evt * @returns void */ onEvent: function(name, evt) { switch (name) { case "htmx:beforeCleanupElement": var internalData = api.getInternalData(evt.target) // Try to remove remove an EventSource when elements are removed if (internalData.sseEventSource) { internalData.sseEventSource.close(); } return; // Try to create EventSources when elements are processed case "htmx:afterProcessNode": ensureEventSourceOnElement(evt.target); registerSSE(evt.target); } } }); /////////////////////////////////////////////// // HELPER FUNCTIONS /////////////////////////////////////////////// /** * createEventSource is the default method for creating new EventSource objects. * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. * * @param {string} url * @returns EventSource */ function createEventSource(url) { return new EventSource(url, { withCredentials: true }); } function splitOnWhitespace(trigger) { return trigger.trim().split(/\s+/); } function getLegacySSEURL(elt) { var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); if (legacySSEValue) { var values = splitOnWhitespace(legacySSEValue); for (var i = 0; i < values.length; i++) { var value = values[i].split(/:(.+)/); if (value[0] === "connect") { return value[1]; } } } } function getLegacySSESwaps(elt) { var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); var returnArr = []; if (legacySSEValue != null) { var values = splitOnWhitespace(legacySSEValue); for (var i = 0; i < values.length; i++) { var value = values[i].split(/:(.+)/); if (value[0] === "swap") { returnArr.push(value[1]); } } } return returnArr; } /** * registerSSE looks for attributes that can contain sse events, right * now hx-trigger and sse-swap and adds listeners based on these attributes too * the closest event source * * @param {HTMLElement} elt */ function registerSSE(elt) { // Find closest existing event source var sourceElement = api.getClosestMatch(elt, hasEventSource); if (sourceElement == null) { // api.triggerErrorEvent(elt, "htmx:noSSESourceError") return null; // no eventsource in parentage, orphaned element } // Set internalData and source var internalData = api.getInternalData(sourceElement); var source = internalData.sseEventSource; // Add message handlers for every `sse-swap` attribute queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); if (sseSwapAttr) { var sseEventNames = sseSwapAttr.split(","); } else { var sseEventNames = getLegacySSESwaps(child); } for (var i = 0; i < sseEventNames.length; i++) { var sseEventName = sseEventNames[i].trim(); var listener = function(event) { // If the source is missing then close SSE if (maybeCloseSSESource(sourceElement)) { return; } // If the body no longer contains the element, remove the listener if (!api.bodyContains(child)) { source.removeEventListener(sseEventName, listener); } // swap the response into the DOM and trigger a notification swap(child, event.data); api.triggerEvent(elt, "htmx:sseMessage", event); }; // Register the new listener api.getInternalData(child).sseEventListener = listener; source.addEventListener(sseEventName, listener); } }); // Add message handlers for every `hx-trigger="sse:*"` attribute queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) { var sseEventName = api.getAttributeValue(child, "hx-trigger"); if (sseEventName == null) { return; } // Only process hx-triggers for events with the "sse:" prefix if (sseEventName.slice(0, 4) != "sse:") { return; } // remove the sse: prefix from here on out sseEventName = sseEventName.substr(4); var listener = function() { if (maybeCloseSSESource(sourceElement)) { return } if (!api.bodyContains(child)) { source.removeEventListener(sseEventName, listener); } } }); } /** * ensureEventSourceOnElement creates a new EventSource connection on the provided element. * If a usable EventSource already exists, then it is returned. If not, then a new EventSource * is created and stored in the element's internalData. * @param {HTMLElement} elt * @param {number} retryCount * @returns {EventSource | null} */ function ensureEventSourceOnElement(elt, retryCount) { if (elt == null) { return null; } // handle extension source creation attribute queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { var sseURL = api.getAttributeValue(child, "sse-connect"); if (sseURL == null) { return; } ensureEventSource(child, sseURL, retryCount); }); // handle legacy sse, remove for HTMX2 queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { var sseURL = getLegacySSEURL(child); if (sseURL == null) { return; } ensureEventSource(child, sseURL, retryCount); }); } function ensureEventSource(elt, url, retryCount) { var source = htmx.createEventSource(url); source.onerror = function(err) { // Log an error event api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); // If parent no longer exists in the document, then clean up this EventSource if (maybeCloseSSESource(elt)) { return; } // Otherwise, try to reconnect the EventSource if (source.readyState === EventSource.CLOSED) { retryCount = retryCount || 0; var timeout = Math.random() * (2 ^ retryCount) * 500; window.setTimeout(function() { ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); }, timeout); } }; source.onopen = function(evt) { api.triggerEvent(elt, "htmx:sseOpen", { source: source }); } api.getInternalData(elt).sseEventSource = source; } /** * maybeCloseSSESource confirms that the parent element still exists. * If not, then any associated SSE source is closed and the function returns true. * * @param {HTMLElement} elt * @returns boolean */ function maybeCloseSSESource(elt) { if (!api.bodyContains(elt)) { var source = api.getInternalData(elt).sseEventSource; if (source != undefined) { source.close(); // source = null return true; } } return false; } /** * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. * * @param {HTMLElement} elt * @param {string} attributeName */ function queryAttributeOnThisOrChildren(elt, attributeName) { var result = []; // If the parent element also contains the requested attribute, then add it to the results too. if (api.hasAttribute(elt, attributeName)) { result.push(elt); } // Search all child nodes that match the requested attribute elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { result.push(node); }); return result; } /** * @param {HTMLElement} elt * @param {string} content */ function swap(elt, content) { api.withExtensions(elt, function(extension) { content = extension.transformResponse(content, null, elt); }); var swapSpec = api.getSwapSpecification(elt); var target = api.getTarget(elt); var settleInfo = api.makeSettleInfo(elt); api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); settleInfo.elts.forEach(function(elt) { if (elt.classList) { elt.classList.add(htmx.config.settlingClass); } api.triggerEvent(elt, 'htmx:beforeSettle'); }); // Handle settle tasks (with delay if requested) if (swapSpec.settleDelay > 0) { setTimeout(doSettle(settleInfo), swapSpec.settleDelay); } else { doSettle(settleInfo)(); } } /** * doSettle mirrors much of the functionality in htmx that * settles elements after their content has been swapped. * TODO: this should be published by htmx, and not duplicated here * @param {import("../htmx").HtmxSettleInfo} settleInfo * @returns () => void */ function doSettle(settleInfo) { return function() { settleInfo.tasks.forEach(function(task) { task.call(); }); settleInfo.elts.forEach(function(elt) { if (elt.classList) { elt.classList.remove(htmx.config.settlingClass); } api.triggerEvent(elt, 'htmx:afterSettle'); }); } } function hasEventSource(node) { return api.getInternalData(node).sseEventSource != null; } })();