291 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			291 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /*
 | |
| 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
 | |
|   }
 | |
| })()
 | 
