/* 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 } }, getSelectors: function() { return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]'] }, /** * onEvent handles all events passed to this extension. * * @param {string} name * @param {Event} evt * @returns void */ onEvent: function(name, evt) { var parent = evt.target || evt.detail.elt switch (name) { case 'htmx:beforeCleanupElement': var internalData = api.getInternalData(parent) // Try to remove remove an EventSource when elements are removed var source = internalData.sseEventSource if (source) { api.triggerEvent(parent, 'htmx:sseClose', { source, type: 'nodeReplaced', }) internalData.sseEventSource.close() } return // Try to create EventSources when elements are processed case 'htmx:afterProcessNode': ensureEventSourceOnElement(parent) } } }) /// //////////////////////////////////////////// // 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 }) } /** * 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) { // Add message handlers for every `sse-swap` attribute if (api.getAttributeValue(elt, 'sse-swap')) { // 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 var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap') var sseEventNames = sseSwapAttr.split(',') for (var i = 0; i < sseEventNames.length; i++) { const sseEventName = sseEventNames[i].trim() const 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(elt)) { source.removeEventListener(sseEventName, listener) return } // swap the response into the DOM and trigger a notification if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { return } swap(elt, event.data) api.triggerEvent(elt, 'htmx:sseMessage', event) } // Register the new listener api.getInternalData(elt).sseEventListener = listener source.addEventListener(sseEventName, listener) } } // Add message handlers for every `hx-trigger="sse:*"` attribute if (api.getAttributeValue(elt, 'hx-trigger')) { // 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 var triggerSpecs = api.getTriggerSpecs(elt) triggerSpecs.forEach(function(ts) { if (ts.trigger.slice(0, 4) !== 'sse:') { return } var listener = function (event) { if (maybeCloseSSESource(sourceElement)) { return } if (!api.bodyContains(elt)) { source.removeEventListener(ts.trigger.slice(4), listener) } // Trigger events to be handled by the rest of htmx htmx.trigger(elt, ts.trigger, event) htmx.trigger(elt, 'htmx:sseMessage', event) } // Register the new listener api.getInternalData(elt).sseEventListener = listener source.addEventListener(ts.trigger.slice(4), 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 if (api.getAttributeValue(elt, 'sse-connect')) { var sseURL = api.getAttributeValue(elt, 'sse-connect') if (sseURL == null) { return } ensureEventSource(elt, sseURL, retryCount) } registerSSE(elt) } 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 }) // 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 retryCount = Math.max(Math.min(retryCount * 2, 128), 1) var timeout = retryCount * 500 window.setTimeout(function() { ensureEventSourceOnElement(elt, retryCount) }, timeout) } } source.onopen = function(evt) { api.triggerEvent(elt, 'htmx:sseOpen', { source }) if (retryCount && retryCount > 0) { const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]") for (let i = 0; i < childrenToFix.length; i++) { registerSSE(childrenToFix[i]) } // We want to increase the reconnection delay for consecutive failed attempts only retryCount = 0 } } api.getInternalData(elt).sseEventSource = source var closeAttribute = api.getAttributeValue(elt, "sse-close"); if (closeAttribute) { // close eventsource when this message is received source.addEventListener(closeAttribute, function() { api.triggerEvent(elt, 'htmx:sseClose', { source, type: 'message', }) source.close() }); } } /** * 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) { api.triggerEvent(elt, 'htmx:sseClose', { source, type: 'nodeMissing', }) source.close() // source = null return true } } return false } /** * @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) api.swap(target, content, swapSpec) } function hasEventSource(node) { return api.getInternalData(node).sseEventSource != null } })()